mirror of
https://github.com/luxfi/bank.git
synced 2026-10-30 17:02:07 +00:00
Every money bug this session was int64 meeting int64. A debt in cents was subtracted from a balance in an asset's own units and a +$340 vault position rendered as -$5,600; a repayment sized in the collateral's decimals reached a contract counting the synthetic's, off by 1e10. The comments carried the unit — CollateralUsd int64 // USD cents — and comments do not typecheck. Cents and Minor are distinct types. The only crossing is usd(), which needs a price, which is why it should be the only one. The compiler found every place they met, including the control in the Earn guard, which deliberately reproduces the old sum: it no longer compiles, so it now casts by hand and says why. money[T] reads a whole amount off a record and rounds, once, instead of at the thirty call sites that each had to remember — one of which had not. An existing database could not boot. Base refuses to update any collection whose name equals its own id, because checkUniqueName looks up that id and does not exclude the collection being saved. Every collection here is NewBaseCollection(name, name), so each was creatable once and never updatable, and a schema addition failed a running bank at startup. The upgrade path saves without that validation; nothing there changes a name. Reproduced on a database written by the older build and now booting clean on this one. The dash also ships as a library. lux.finance renders it whole; the Lux Cloud console renders the same screens beside a validator's nodes and keys. bankd validates a bearer at IAM's userinfo without checking audience, so the console's Lux ID session already authorizes the bank — no second sign-in, no exchange. The grid compiler runs at library build time, so a host needs no plugin of its own. Colour belonged to the brand tokens but the layout runtime painted body from its own theme, unlayered, so everything that inherited landed on the wrong brand — white text on the light brand's white page, across the whole app. Co-authored-by: Hanzo Dev <[email protected]>
88 lines
2.7 KiB
Go
88 lines
2.7 KiB
Go
package bank
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Exchange rate source
|
|
//
|
|
// One function decides where a price comes from, so the exchange handlers never
|
|
// have to know which mode they are running in. Sandbox prices off the local
|
|
// reference tables; live asks forexd, the only component holding liquidity-venue
|
|
// credentials. A live deployment without forexd cannot price an exchange —
|
|
// there is deliberately no fallback to the sandbox tables.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// forexProvider is the forexd adapter that prices live exchanges. Kraken is
|
|
// the spot backend for crypto + fiat conversion; override with FOREX_PROVIDER.
|
|
func forexProvider() string {
|
|
if p := strings.TrimSpace(os.Getenv("FOREX_PROVIDER")); p != "" {
|
|
return p
|
|
}
|
|
return "kraken"
|
|
}
|
|
|
|
// exchangeRate prices `amount` minor units of `from` into minor units of `to`,
|
|
// and returns the effective rate (whole `to` per 1 whole `from`).
|
|
func exchangeRate(ctx context.Context, from, to string, amount Minor) (Minor, float64, error) {
|
|
if Sandbox() {
|
|
toMinor, rate := convertMinor(amount, from, to)
|
|
return toMinor, rate, nil
|
|
}
|
|
rate, err := forexRate(ctx, from, to)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
// whole `from` = amount / 10^fromDp; whole `to` = whole `from` * rate.
|
|
fromWhole := float64(amount) / math.Pow10(decimalsFor(from))
|
|
toMinor := round[Minor](fromWhole * rate * math.Pow10(decimalsFor(to)))
|
|
return toMinor, rate, nil
|
|
}
|
|
|
|
// forexRate asks forexd for the spot rate (whole `to` per 1 whole `from`) via
|
|
// GET /v1/{provider}/rate/{base}/{quote} — the canonical forexd contract.
|
|
func forexRate(ctx context.Context, from, to string) (float64, error) {
|
|
forexURL := strings.TrimRight(os.Getenv("FOREX_SERVICE_URL"), "/")
|
|
if forexURL == "" {
|
|
return 0, errors.New("FOREX_SERVICE_URL not set")
|
|
}
|
|
endpoint := fmt.Sprintf("%s/v1/%s/rate/%s/%s",
|
|
forexURL, url.PathEscape(forexProvider()),
|
|
url.PathEscape(strings.ToUpper(from)), url.PathEscape(strings.ToUpper(to)))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return 0, fmt.Errorf("forex rate returned %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
Rate float64 `json:"rate"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
|
|
return 0, err
|
|
}
|
|
if out.Rate <= 0 {
|
|
return 0, fmt.Errorf("forex rate unpriced for %s → %s", from, to)
|
|
}
|
|
return out.Rate, nil
|
|
}
|