diff options
author | Cypher <cypher@server.ky> | 2023-09-30 07:56:58 -0500 |
---|---|---|
committer | Cypher <cypher@server.ky> | 2023-09-30 08:01:55 -0500 |
commit | e62ca945d169a9aa5ced9658f89266697c5fe014 (patch) | |
tree | 538abb9acac71a27f76a50c2dd63c9958cdb8e13 | |
parent | 7b94533aed7b5eea4f395905da94060cd4398430 (diff) | |
download | librefund-e62ca945d169a9aa5ced9658f89266697c5fe014.tar.xz |
Quote price in alternate currency
Crypto token price change rapidly. Make it easier for contributors to
gauge amounts by optionally displaying values in a different currency.
-rw-r--r-- | asset/template/contribute.html.tmpl | 17 | ||||
-rw-r--r-- | asset/template/money.html.tmpl | 4 | ||||
-rw-r--r-- | asset/template/project.html.tmpl | 8 | ||||
-rw-r--r-- | config.go | 15 | ||||
-rw-r--r-- | external/coingecko.go | 145 | ||||
-rw-r--r-- | external/coingecko_test.go | 15 | ||||
-rw-r--r-- | http/html_template.go | 30 | ||||
-rw-r--r-- | http/http.go | 2 | ||||
-rw-r--r-- | service/service.go | 16 |
9 files changed, 231 insertions, 21 deletions
diff --git a/asset/template/contribute.html.tmpl b/asset/template/contribute.html.tmpl index 6efc537..a5fbe25 100644 --- a/asset/template/contribute.html.tmpl +++ b/asset/template/contribute.html.tmpl @@ -11,12 +11,19 @@ <h2>Contribute to "{{toTitle .Objective.Name}}"</h2> <ul class="in-short"> {{if .Objective.IsCrowdMatched}} - <li>We ask for <strong>{{.Objective.MinimumLimit.Display}} to {{.Objective.FundingLimit.Display}}</strong>. 🎯 + <li>We ask for <strong> + {{template "money.html.tmpl" .Objective.MinimumLimit}} + to + {{template "money.html.tmpl" .Objective.FundingLimit}} + </strong>. 🎯 </li> {{end}} - <li>We raised <strong>{{.Objective.TotalMatched.Display}}</strong>. 🔮 + <li>We raised + <strong> + {{template "money.html.tmpl" .Objective.TotalMatched}} + </strong>. 🔮 </li> - <li>For matching new contributions, we have <strong>{{.Objective.TotalRefunded.Display}}</strong>. 👫 + <li>For matching new contributions, we have <strong>{{template "money.html.tmpl" .Objective.TotalRefunded}}</strong>. 👫 </li> {{if .Objective.IsCrowdMatched}} <li>Previous contributed amounts will currently be refunded <strong>{{.Objective.TotalRefundedPerc}}</strong>. ↩<li> @@ -64,7 +71,7 @@ <div id="contribute-recommended-amounts" class="input-group inline"> {{range $key, $value := .SuggestedContributions}} <input type="radio" id="amount-{{$key}}" name="Amount" value="{{$value.DisplayAmount}}" {{if eq $key 0}} checked="true" {{end}}> - <label for="amount-{{$key}}">{{$value.Display}}</label> + <label for="amount-{{$key}}">{{template "money.html.tmpl" $value}}</label> {{end}} </div> @@ -73,7 +80,7 @@ <div class="input-group-prefix">{{.Currency}}</div> <input type="number" value="{{.SuggestedContribution.DisplayAmount}}" step="{{.SuggestedContributionStep}}" name="AmountCustom" placeholder="e.g: {{.MaxContribution.DisplayAmount}}"> </div> - <p class="instruction"><small>Min: {{.MinContribution.Display}}, Max: {{.MaxContribution.Display}}</small></p> + <p class="instruction"><small>Min: {{template "money.html.tmpl" .MinContribution}}, Max: {{template "money.html.tmpl" .MaxContribution}}</small></p> {{if eq "bitcoin" .TransferMethod}} <h3>Refund address</h3> diff --git a/asset/template/money.html.tmpl b/asset/template/money.html.tmpl new file mode 100644 index 0000000..8594b7d --- /dev/null +++ b/asset/template/money.html.tmpl @@ -0,0 +1,4 @@ +{{.Display}} +{{with altCurrency .}} + ({{.Display}}) +{{end}} diff --git a/asset/template/project.html.tmpl b/asset/template/project.html.tmpl index 20be4dd..877210e 100644 --- a/asset/template/project.html.tmpl +++ b/asset/template/project.html.tmpl @@ -18,7 +18,10 @@ <p class="description">{{.ShortDescription}}</p> <ul class="stats"> {{if .IsCrowdMatched}} - <li class="stat">🎯 Asking {{.MinimumLimit.Display}} to {{.FundingLimit.Display}} + <li class="stat">🎯 Asking + {{template "money.html.tmpl" .MinimumLimit}} + to + {{template "money.html.tmpl" .FundingLimit}} <span class="tooltip">? <span class="tooltip-text"> How much is being asked for to start the objective. @@ -26,7 +29,8 @@ </span> </li> {{end}} - <li class="stat">🔮 Raised {{.TotalMatched.Display}} + <li class="stat">🔮 Raised + {{template "money.html.tmpl" .TotalMatched}} <span class="tooltip">? <span class="tooltip-text"> Total non-refunded value raised from all contributions. @@ -103,13 +103,16 @@ type Config struct { ProjectDirectory *string `yaml:"project-directory,omitempty"` ServiceAgreementEnable *bool `yaml:"service-agreement-enable,omitempty"` ServiceAgreementFile *string `yaml:"service-agreement-file,omitempty"` - SMTPAddress *string `yaml:"smtp-address,omitempty"` - SMTPNotifyContribution *bool `yaml:"smtp-notify-contribution,omitempty"` - SMTPPassword *string `yaml:"smtp-password,omitempty"` - SMTPRecipients *string `yaml:"smtp-recipients,omitempty"` - SMTPUser *string `yaml:"smtp-user,omitempty"` + // ShowAlternativeCurrency will show money in an additional currency to + // aid users when dealing with crypto tokens. + ShowAlternativeCurrency *string `yaml:"show-alternative-currency"` + SMTPAddress *string `yaml:"smtp-address,omitempty"` + SMTPNotifyContribution *bool `yaml:"smtp-notify-contribution,omitempty"` + SMTPPassword *string `yaml:"smtp-password,omitempty"` + SMTPRecipients *string `yaml:"smtp-recipients,omitempty"` + SMTPUser *string `yaml:"smtp-user,omitempty"` // SuggestedContributions is a list of recommended amounts when contributing. - SuggestedContributions map[string][]decimal.Decimal `yaml:"suggested-contributions"` + SuggestedContributions map[string][]decimal.Decimal `yaml:"suggested-contributions,omitempty"` // TestPaymentGatewayEnable enables an always successful payment // gateway which accepts all currencies. // diff --git a/external/coingecko.go b/external/coingecko.go new file mode 100644 index 0000000..ad773a9 --- /dev/null +++ b/external/coingecko.go @@ -0,0 +1,145 @@ +package external + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "sync" + "time" + + "git.server.ky/cypher/librefund" + "git.server.ky/cypher/librefund/payment/bitcoin" + "github.com/Rhymond/go-money" + "github.com/pkg/errors" + "github.com/shopspring/decimal" +) + +const coingeckoAPI = "https://api.coingecko.com/api/v3/simple/price" + +type symbolPair struct { + From string + To string +} + +func (s symbolPair) Swap() symbolPair { + return symbolPair{ + From: s.To, + To: s.From, + } +} +func (s symbolPair) String() string { + return fmt.Sprintf("(%s, %s)", s.From, s.To) +} + +func (s *symbolPair) UnmarshalText() string { + return fmt.Sprintf("(%s, %s)", s.From, s.To) + +} + +// CoinGecko is a quick and dirty solution to fetch BTC prices without +// importing a supporting package. +// +// It is configured to use IPv4 for HTTP requests to avoid rate limiting. +type CoinGecko struct { + mut sync.Mutex + + client *http.Client + interval time.Duration + lastupdated map[symbolPair]time.Time + price map[symbolPair]decimal.Decimal +} + +func NewCoinGecko() *CoinGecko { + s := &CoinGecko{ + interval: time.Minute, + lastupdated: make(map[symbolPair]time.Time), + price: make(map[symbolPair]decimal.Decimal), + } + s.setupHttpClient() + + return s +} + +func (s *CoinGecko) setupHttpClient() { + client := &http.Client{ + Timeout: 5 * time.Second, + } + + var zeroDialer net.Dialer + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return zeroDialer.DialContext(ctx, "tcp4", addr) + } + client.Transport = transport + s.client = client +} + +func (s *CoinGecko) updatePrice(pair symbolPair) error { + if pair.From != "BTC" { + return errors.New("only BTC supported") + } + + p := fmt.Sprintf("%s?ids=%s&vs_currencies=%s", coingeckoAPI, "bitcoin", pair.To) + resp, err := s.client.Get(p) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return errors.New("bad status code") + } + + payload := make(map[string]map[string]decimal.Decimal) + err = json.NewDecoder(resp.Body).Decode(&payload) + if err != nil { + return err + } + + v, ok := payload["bitcoin"] + if !ok { + return errors.New("malformated payload") + } + v2, ok := v[strings.ToLower(pair.To)] + if !ok { + return errors.New("malformated payload") + } + + s.price[pair] = v2 + s.lastupdated[pair] = time.Now() + return nil +} + +// GetPrice returns the estimated value for the amount in the given +// currency. +func (s *CoinGecko) QuotePrice(curCode string, amount *librefund.Money) (*librefund.Money, error) { + s.mut.Lock() + defer s.mut.Unlock() + + inCur := money.GetCurrency(curCode) + pair := symbolPair{ + From: amount.Currency().Code, + To: inCur.Code, + } + if pair.From != bitcoin.CurrencyCode { + return nil, errors.New("can only convert BTC") + } + + lastupdate, ok := s.lastupdated[pair] + if !ok || time.Now().Sub(lastupdate) > s.interval { + err := s.updatePrice(pair) + if err != nil { + return nil, err + } + } + price := s.price[pair] + + convAmount := librefund.Decimal(amount).Mul(price) + convAmount = convAmount.Round(int32(inCur.Fraction)) + conv, err := librefund.NewMoneyFromDecimal(convAmount, inCur.Code) + if err != nil { + return nil, errors.Wrapf(err, "getting price for %s", pair) + } + return conv, nil +} diff --git a/external/coingecko_test.go b/external/coingecko_test.go new file mode 100644 index 0000000..fa2d3a9 --- /dev/null +++ b/external/coingecko_test.go @@ -0,0 +1,15 @@ +package external + +import ( + "testing" + + "git.server.ky/cypher/librefund" + "github.com/stretchr/testify/require" +) + +func TestCoinGeckoGetPrice(t *testing.T) { + cg := NewCoinGecko() + convAmount, err := cg.QuotePrice("USD", librefund.MustMoney("1", "BTC")) + require.NoError(t, err, "interfacing with coingecko should be ok") + require.NotZero(t, convAmount.Amount(), "expect conversion to sane amount") +} diff --git a/http/html_template.go b/http/html_template.go index 68ccc29..c4c22e4 100644 --- a/http/html_template.go +++ b/http/html_template.go @@ -18,18 +18,32 @@ import ( "github.com/shopspring/decimal" ) +func AltCurrency( + pricer service.QuotePricer, + curCode *string, +) func(*librefund.Money) *librefund.Money { + return func(arg *librefund.Money) *librefund.Money { + if curCode == nil { + return nil + } + res, _ := pricer.QuotePrice(*curCode, arg) + return res + } +} + func ToTitle(arg string) string { title := strings.ReplaceAll(arg, "-", " ") title = strings.Title(title) return title } -func initTemplates(h *HTTPServer) (*template.Template, error) { +func initTemplates(cfg *librefund.Config, h *HTTPServer) (*template.Template, error) { funcMap := template.FuncMap{ - "add": add, - "qrcodeURL": h.qrcodeURL, - "url": h.url, - "toTitle": ToTitle, + "add": add, + "altCurrency": AltCurrency(h.srv, h.srv.Config.ShowAlternativeCurrency), + "qrcodeURL": h.qrcodeURL, + "url": h.url, + "toTitle": ToTitle, } entries, err := librefund.AssetDir.ReadDir("asset/template") @@ -145,6 +159,7 @@ type tplObjective struct { func newTPLObjective( cfg *librefund.Config, obj *librefund.Objective, + quotePricer service.QuotePricer, ) (*tplObjective, error) { tObj := tplObjective{ Objective: obj, @@ -164,6 +179,7 @@ func newTPLObjective( if err != nil { return nil, err } + return &tObj, nil } @@ -228,7 +244,7 @@ func newTPLGetProjectFields( } sort.Sort(objectivesByName(objs)) for _, v := range objs { - obj, err := newTPLObjective(&srv.Config, v) + obj, err := newTPLObjective(&srv.Config, v, srv) if err != nil { return nil, err } @@ -424,7 +440,7 @@ func newTPLContributeFields( if err != nil { return nil, err } - ret.Objective, err = newTPLObjective(&srv.Config, obj) + ret.Objective, err = newTPLObjective(&srv.Config, obj, srv) if err != nil { return nil, err } diff --git a/http/http.go b/http/http.go index 789270a..f1f6f73 100644 --- a/http/http.go +++ b/http/http.go @@ -114,7 +114,7 @@ func (h *HTTPServer) initRoutes() error { h.router = mux.NewRouter() var err error - h.templates, err = initTemplates(h) + h.templates, err = initTemplates(&h.srv.Config, h) if err != nil { return err } diff --git a/service/service.go b/service/service.go index c883ac6..417fdcb 100644 --- a/service/service.go +++ b/service/service.go @@ -12,6 +12,7 @@ import ( "time" "git.server.ky/cypher/librefund" + "git.server.ky/cypher/librefund/external" "git.server.ky/cypher/librefund/payment" "git.server.ky/cypher/librefund/payment/bitcoin" "git.server.ky/cypher/librefund/payment/taler" @@ -27,6 +28,11 @@ const ConfigPath = "etc/librefund.yaml" // refundDeadline is a safety duration added to a payments refund deadline. const refundDeadline = 7 * 24 * time.Hour +// QuotePricer returns the conversion amount of a given currency amount. +type QuotePricer interface { + QuotePrice(curCode string, base *librefund.Money) (*librefund.Money, error) +} + // TransferMethodFor returns the supported transfer method for the given // currency. // @@ -52,6 +58,7 @@ type Service struct { Config librefund.Config + // payments bitcoinPG *bitcoin.PaymentGateway taler *talerAdapter.Adapter testPG *payment.TestPaymentGateway @@ -70,6 +77,7 @@ type Service struct { agreementsFile *os.File eventLog *librefund.EventLog maintainerUI *librefund.FileSystemUserInterface + quotePricer QuotePricer scheduler *Scheduler smtpNotifier *SMTPNotifier store *librefund.FileSystemStore @@ -106,6 +114,8 @@ func NewService(cfg librefund.Config) (*Service, error) { return nil, err } + ret.quotePricer = external.NewCoinGecko() + ret.scheduler = NewScheduler() ret.scheduler.RunEvery(5*time.Minute, ret.refreshProjectInfo) @@ -783,6 +793,12 @@ func (s *Service) ContributeBitcoin( return payInfo, nil } +// QuotePrice returns the last known quoted conversion amount to the given +// currency. +func (s *Service) QuotePrice(curCode string, base *librefund.Money) (*librefund.Money, error) { + return s.quotePricer.QuotePrice(curCode, base) +} + func (s *Service) GetProjectContributions( project string, count, offset int, |