aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCypher <cypher@server.ky>2023-09-30 07:56:58 -0500
committerCypher <cypher@server.ky>2023-09-30 08:01:55 -0500
commite62ca945d169a9aa5ced9658f89266697c5fe014 (patch)
tree538abb9acac71a27f76a50c2dd63c9958cdb8e13
parent7b94533aed7b5eea4f395905da94060cd4398430 (diff)
downloadlibrefund-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.tmpl17
-rw-r--r--asset/template/money.html.tmpl4
-rw-r--r--asset/template/project.html.tmpl8
-rw-r--r--config.go15
-rw-r--r--external/coingecko.go145
-rw-r--r--external/coingecko_test.go15
-rw-r--r--http/html_template.go30
-rw-r--r--http/http.go2
-rw-r--r--service/service.go16
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.
diff --git a/config.go b/config.go
index dcd3576..c9e9bfd 100644
--- a/config.go
+++ b/config.go
@@ -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,