mirror of
https://github.com/luxfi/bank.git
synced 2026-10-30 17:02:07 +00:00
The declare-address route and the health field land with the documentation that describes them, which had been left behind: LLM.md is the real file and CLAUDE.md is a symlink to it, so staging the symlink stages nothing. Custody.tsx stated the bank posture as though it were the only one. It is true of every deployment today and will not be true of a holder one, and a custody claim that cannot follow the deployment outlives it — so it reads the field rather than asserting the answer. Its chainSeed reference named a function that no longer exists. Co-authored-by: Hanzo Dev <[email protected]>
476 lines
16 KiB
Go
476 lines
16 KiB
Go
package bank
|
|
|
|
import (
|
|
"math"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/base/apis"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/hook"
|
|
"github.com/luxfi/bank/collections"
|
|
)
|
|
|
|
// RegisterRoutes adds custom HTTP endpoints to the Base app.
|
|
func RegisterRoutes(app core.App) {
|
|
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
|
Id: "bankRoutes",
|
|
Func: func(e *core.ServeEvent) error {
|
|
// Public health/config (unauthenticated). Custody is here because
|
|
// who holds a customer's key is the fact every screen that moves
|
|
// crypto has to agree with, and a surface that assumes it instead
|
|
// of asking will one day print the wrong sentence to a customer.
|
|
e.Router.GET("/v1/bank/health", func(re *core.RequestEvent) error {
|
|
return re.JSON(http.StatusOK, map[string]any{
|
|
"status": "ok", "sandbox": Sandbox(), "custody": custodian().Name(),
|
|
})
|
|
})
|
|
// Membership ladder — one source for lux.finance, lux.credit,
|
|
// and the dash pricing surfaces.
|
|
e.Router.GET("/v1/bank/plans", func(re *core.RequestEvent) error {
|
|
return re.JSON(http.StatusOK, collections.Plans)
|
|
})
|
|
// Liquid Protocol vault catalog — the Earn markets, public like
|
|
// the plan ladder (positions live behind auth at /earn/vaults).
|
|
e.Router.GET("/v1/bank/vaults", func(re *core.RequestEvent) error {
|
|
return re.JSON(http.StatusOK, collections.Vaults)
|
|
})
|
|
e.Router.GET("/v1/bank/config", func(re *core.RequestEvent) error {
|
|
network := "lux-mainnet"
|
|
disclaimer := "Banking services are provided by SF Private Bank LTD, International Banking License (L17601/SFPB) " +
|
|
"under Union of Comoros Offshore Finance Authority (FSA Offshore), and SF Neobanq Kommanditbolag " +
|
|
"registered in Stockholm (969802-1251), issued by Bolagsverket."
|
|
if Sandbox() {
|
|
network = "lux-testnet"
|
|
disclaimer = "Demo — " + disclaimer + " Sandbox environment, not for real deposits."
|
|
}
|
|
// The demo identity is only named in sandbox — production must
|
|
// never publish a login identity from an unauthenticated route.
|
|
demoEmail := ""
|
|
if Sandbox() {
|
|
demoEmail = DemoEmail()
|
|
}
|
|
return re.JSON(http.StatusOK, map[string]any{
|
|
"sandbox": Sandbox(),
|
|
"demoEmail": demoEmail,
|
|
"fiat": SupportedFiat,
|
|
"crypto": SupportedCrypto,
|
|
"network": network,
|
|
"disclaimer": disclaimer,
|
|
"partner": map[string]string{
|
|
"name": "SF Private Bank",
|
|
"terms": "https://www.sf-privatebank.com/terms",
|
|
"privacy": "https://www.sf-privatebank.com/privacy-policy",
|
|
},
|
|
})
|
|
})
|
|
|
|
g := e.Router.Group("/v1/bank")
|
|
g.Bind(apis.RequireAuth())
|
|
|
|
// Onboarding + dashboard.
|
|
g.POST("/onboard", handleOnboard(app))
|
|
g.GET("/overview", handleOverview(app))
|
|
g.GET("/transactions", handleListTransactions(app))
|
|
g.GET("/account/summary", handleAccountSummary(app))
|
|
|
|
// Money movement.
|
|
g.POST("/transfers", handleTransfer(app))
|
|
g.POST("/payments/outbound", handleOutboundPayment(app))
|
|
|
|
// Per-account reads.
|
|
g.GET("/accounts/{id}/balances", handleGetBalances(app))
|
|
g.GET("/accounts/{id}/wallets", handleGetWallets(app))
|
|
g.GET("/accounts/{id}/transactions", handleGetTransactions(app))
|
|
|
|
// Where the customer holds the key, the bank has no address until
|
|
// they say so. Where the bank holds it, there is nothing to say and
|
|
// the route is absent rather than present and refusing — a 404 is
|
|
// the honest answer to "let me tell you my address" from a
|
|
// deployment that already derived one.
|
|
if selfCustody() {
|
|
g.POST("/accounts/{id}/address", handleDeclareAddress(app))
|
|
}
|
|
|
|
// Beneficiaries.
|
|
g.GET("/beneficiaries", handleListBeneficiaries(app))
|
|
g.POST("/beneficiaries", handleCreateBeneficiary(app))
|
|
g.DELETE("/beneficiaries/{id}", handleDeleteBeneficiary(app))
|
|
|
|
// Cards.
|
|
g.GET("/cards", handleListCards(app))
|
|
g.POST("/cards", handleIssueCard(app))
|
|
g.POST("/cards/{id}/freeze", setCardStatus(app, "frozen"))
|
|
g.POST("/cards/{id}/unfreeze", setCardStatus(app, "active"))
|
|
|
|
// Issuer card account (provider-neutral; see issuer.go).
|
|
registerIssuerRoutes(g)
|
|
|
|
// Exchange (fiat FX + crypto buy/sell/convert) and wallet.
|
|
g.POST("/exchange/quote", handleExchangeQuote(app))
|
|
g.POST("/exchange/execute", handleExchangeExecute(app))
|
|
g.GET("/wallet", handleGetWallet(app))
|
|
g.GET("/crypto/prices", handleCryptoPrices(app))
|
|
|
|
// Crypto movement — send on-chain; the deposit faucet is
|
|
// sandbox-only so the full testnet loop is exercisable.
|
|
g.POST("/crypto/send", handleCryptoSend(app))
|
|
if Sandbox() {
|
|
g.POST("/crypto/deposit", handleCryptoDeposit(app))
|
|
}
|
|
|
|
// Earn — Liquid Protocol vaults (deposit / borrow / repay / withdraw).
|
|
registerEarnRoutes(app, g)
|
|
|
|
return e.Next()
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleAccountSummary returns a compact list of the caller's accounts.
|
|
func handleAccountSummary(app core.App) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
records, err := app.FindRecordsByFilter(
|
|
collections.AccountCollectionName, "owner = {:userId}", "-created", 10, 0,
|
|
map[string]any{"userId": e.Auth.Id},
|
|
)
|
|
if err != nil {
|
|
return apis.NewBadRequestError("failed to fetch accounts", nil)
|
|
}
|
|
out := make([]accountView, 0, len(records))
|
|
for _, r := range records {
|
|
out = append(out, viewAccount(r))
|
|
}
|
|
return e.JSON(http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// -- Transfer (internal move between own accounts) --
|
|
|
|
type transferRequest struct {
|
|
FromAccountID string `json:"fromAccountId"`
|
|
ToAccountID string `json:"toAccountId"`
|
|
Amount int64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
Reference string `json:"reference"`
|
|
}
|
|
|
|
func handleTransfer(app core.App) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
req, err := bindBody[transferRequest](e)
|
|
if err != nil {
|
|
return errJSON(e, http.StatusBadRequest, "invalid payload")
|
|
}
|
|
|
|
if req.FromAccountID == "" || req.ToAccountID == "" || req.Amount <= 0 || req.Currency == "" {
|
|
return errJSON(e, http.StatusBadRequest, "missing required fields")
|
|
}
|
|
|
|
// Verify caller owns the source account.
|
|
from, err := app.FindRecordById(collections.AccountCollectionName, req.FromAccountID)
|
|
if err != nil {
|
|
return apis.NewNotFoundError("source account not found", nil)
|
|
}
|
|
if from.GetString("owner") != e.Auth.Id {
|
|
return apis.NewForbiddenError("not your account", nil)
|
|
}
|
|
|
|
// Verify destination exists and belongs to the caller.
|
|
to, err := app.FindRecordById(collections.AccountCollectionName, req.ToAccountID)
|
|
if err != nil {
|
|
return apis.NewNotFoundError("destination account not found", nil)
|
|
}
|
|
if to.GetString("owner") != e.Auth.Id {
|
|
return apis.NewForbiddenError("destination account not owned by caller", nil)
|
|
}
|
|
|
|
// Create debit transaction (hooks will validate balance, KYC, limits).
|
|
txCollection, err := app.FindCollectionByNameOrId(collections.TransactionCollectionName)
|
|
if err != nil {
|
|
return apis.NewInternalServerError("", nil)
|
|
}
|
|
|
|
// Both legs are one act. Written separately, a credit the hooks refuse
|
|
// — the destination has not passed KYC, say, which is checked on every
|
|
// transaction and not only on the outbound one — left the debit saved
|
|
// and holding the sender's funds against a transfer that had already
|
|
// been reported as failed. Either the money moves or nothing did.
|
|
//
|
|
// Nesting is safe as long as everything inside uses the callback's app,
|
|
// so the balance hooks join this transaction rather than opening their
|
|
// own.
|
|
debit := core.NewRecord(txCollection)
|
|
debit.Set("account", req.FromAccountID)
|
|
debit.Set("type", "payment")
|
|
debit.Set("direction", "debit")
|
|
debit.Set("amount", req.Amount)
|
|
debit.Set("currency", req.Currency)
|
|
debit.Set("status", "pending")
|
|
debit.Set("reference", req.Reference)
|
|
|
|
credit := core.NewRecord(txCollection)
|
|
credit.Set("account", req.ToAccountID)
|
|
credit.Set("type", "deposit")
|
|
credit.Set("direction", "credit")
|
|
credit.Set("amount", req.Amount)
|
|
credit.Set("currency", req.Currency)
|
|
credit.Set("status", "pending")
|
|
credit.Set("reference", req.Reference)
|
|
|
|
status := "pending"
|
|
var unsettled error
|
|
if err := app.RunInTransaction(func(txApp core.App) error {
|
|
if err := txApp.Save(debit); err != nil {
|
|
return err
|
|
}
|
|
if err := txApp.Save(credit); err != nil {
|
|
return err
|
|
}
|
|
if !Sandbox() {
|
|
return nil
|
|
}
|
|
// Instant, deterministic settlement for the demo.
|
|
for _, leg := range []*core.Record{debit, credit} {
|
|
if err := settle(txApp, leg); err != nil {
|
|
unsettled = err
|
|
return err
|
|
}
|
|
}
|
|
status = "completed"
|
|
return nil
|
|
}); err != nil {
|
|
if unsettled != nil {
|
|
return apis.NewInternalServerError("settlement failed", unsettled)
|
|
}
|
|
return errJSON(e, http.StatusUnprocessableEntity, err.Error())
|
|
}
|
|
|
|
return e.JSON(http.StatusCreated, map[string]any{
|
|
"debitId": debit.Id,
|
|
"creditId": credit.Id,
|
|
"status": status,
|
|
})
|
|
}
|
|
}
|
|
|
|
// -- Outbound payment (to external beneficiary) --
|
|
|
|
type outboundPaymentRequest struct {
|
|
AccountID string `json:"accountId"`
|
|
BeneficiaryID string `json:"beneficiaryId"`
|
|
Amount int64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
Reference string `json:"reference"`
|
|
}
|
|
|
|
func handleOutboundPayment(app core.App) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
req, err := bindBody[outboundPaymentRequest](e)
|
|
if err != nil {
|
|
return errJSON(e, http.StatusBadRequest, "invalid payload")
|
|
}
|
|
|
|
if req.AccountID == "" || req.BeneficiaryID == "" || req.Amount <= 0 || req.Currency == "" {
|
|
return errJSON(e, http.StatusBadRequest, "missing required fields")
|
|
}
|
|
|
|
// Verify ownership.
|
|
account, err := app.FindRecordById(collections.AccountCollectionName, req.AccountID)
|
|
if err != nil {
|
|
return apis.NewNotFoundError("account not found", nil)
|
|
}
|
|
if account.GetString("owner") != e.Auth.Id {
|
|
return apis.NewForbiddenError("not your account", nil)
|
|
}
|
|
|
|
// Verify beneficiary belongs to this account.
|
|
bene, err := app.FindRecordById(collections.BeneficiaryCollectionName, req.BeneficiaryID)
|
|
if err != nil {
|
|
return apis.NewNotFoundError("beneficiary not found", nil)
|
|
}
|
|
if bene.GetString("account") != req.AccountID {
|
|
return apis.NewForbiddenError("beneficiary does not belong to this account", nil)
|
|
}
|
|
|
|
txCollection, err := app.FindCollectionByNameOrId(collections.TransactionCollectionName)
|
|
if err != nil {
|
|
return apis.NewInternalServerError("", nil)
|
|
}
|
|
|
|
tx := core.NewRecord(txCollection)
|
|
tx.Set("account", req.AccountID)
|
|
tx.Set("beneficiary", req.BeneficiaryID)
|
|
tx.Set("type", "payment")
|
|
tx.Set("direction", "debit")
|
|
tx.Set("amount", req.Amount)
|
|
tx.Set("currency", req.Currency)
|
|
tx.Set("status", "pending")
|
|
tx.Set("reference", req.Reference)
|
|
|
|
if err := app.Save(tx); err != nil {
|
|
return errJSON(e, http.StatusUnprocessableEntity, err.Error())
|
|
}
|
|
|
|
status := "pending"
|
|
if Sandbox() {
|
|
// Simulated SWIFT/SEPA rail: settle immediately in the demo.
|
|
if err := settle(app, tx); err != nil {
|
|
return apis.NewInternalServerError("settlement failed", err)
|
|
}
|
|
status = "completed"
|
|
}
|
|
|
|
return e.JSON(http.StatusCreated, map[string]any{
|
|
"transactionId": tx.Id,
|
|
"status": status,
|
|
})
|
|
}
|
|
}
|
|
|
|
// -- Account balances --
|
|
|
|
func handleGetBalances(app core.App) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
accountId := e.Request.PathValue("id")
|
|
|
|
// Verify ownership.
|
|
account, err := app.FindRecordById(collections.AccountCollectionName, accountId)
|
|
if err != nil {
|
|
return apis.NewNotFoundError("account not found", nil)
|
|
}
|
|
if account.GetString("owner") != e.Auth.Id {
|
|
return apis.NewForbiddenError("not your account", nil)
|
|
}
|
|
|
|
balances, err := app.FindRecordsByFilter(
|
|
collections.BalanceCollectionName,
|
|
`account = {:accountId}`,
|
|
"currency",
|
|
0,
|
|
0,
|
|
map[string]any{"accountId": accountId},
|
|
)
|
|
if err != nil {
|
|
return apis.NewInternalServerError("", nil)
|
|
}
|
|
|
|
type balanceResponse struct {
|
|
Currency string `json:"currency"`
|
|
Available int64 `json:"available"`
|
|
Held int64 `json:"held"`
|
|
}
|
|
|
|
result := make([]balanceResponse, 0, len(balances))
|
|
for _, b := range balances {
|
|
result = append(result, balanceResponse{
|
|
Currency: b.GetString("currency"),
|
|
Available: int64(math.Round(b.GetFloat("available"))),
|
|
Held: int64(math.Round(b.GetFloat("held"))),
|
|
})
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, result)
|
|
}
|
|
}
|
|
|
|
// -- Account wallets --
|
|
|
|
func handleGetWallets(app core.App) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
accountId := e.Request.PathValue("id")
|
|
account, err := app.FindRecordById(collections.AccountCollectionName, accountId)
|
|
if err != nil {
|
|
return apis.NewNotFoundError("account not found", nil)
|
|
}
|
|
if account.GetString("owner") != e.Auth.Id {
|
|
return apis.NewForbiddenError("not your account", nil)
|
|
}
|
|
// The same shape /v1/bank/wallet answers with. This built its own,
|
|
// which carried the custodian's internal handle and left out the
|
|
// address — the one thing a wallet is for, so the route could not be
|
|
// used for the thing it names.
|
|
return e.JSON(http.StatusOK, viewWallets(app, accountId))
|
|
}
|
|
}
|
|
|
|
// -- The address a customer holds --
|
|
|
|
type declareAddressReq struct {
|
|
Address string `json:"address"`
|
|
}
|
|
|
|
// handleDeclareAddress records where an account's owner holds, on a deployment
|
|
// where they hold their own key. The bank never learns anything but the
|
|
// address, which is a public value; it derives nothing and cannot spend from it.
|
|
//
|
|
// Declaring is idempotent and re-declaring is allowed, because a customer moves
|
|
// wallets — to a Safe, to new hardware, off a lost device. What it changes is
|
|
// only where money arrives next; the ledger and the history are untouched, and
|
|
// nothing already on chain follows the change.
|
|
func handleDeclareAddress(app core.App) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
account, err := app.FindRecordById(collections.AccountCollectionName, e.Request.PathValue("id"))
|
|
if err != nil {
|
|
return apis.NewNotFoundError("account not found", nil)
|
|
}
|
|
if account.GetString("owner") != e.Auth.Id {
|
|
return apis.NewForbiddenError("not your account", nil)
|
|
}
|
|
req, err := bindBody[declareAddressReq](e)
|
|
if err != nil {
|
|
return apis.NewBadRequestError("invalid payload", err)
|
|
}
|
|
// What counts as an address is the configured chain's judgment. The
|
|
// asset is empty because there is one address here: on an EVM an account
|
|
// receives the coin and every token at the same place.
|
|
addr := strings.TrimSpace(req.Address)
|
|
if !chain().Valid("", addr) {
|
|
return apis.NewBadRequestError("not an address on this network", nil)
|
|
}
|
|
account.Set("address", addr)
|
|
if err := app.Save(account); err != nil {
|
|
return errJSON(e, http.StatusUnprocessableEntity, "could not record the address")
|
|
}
|
|
ensureWallets(app, account)
|
|
return e.JSON(http.StatusOK, viewWallets(app, account.Id))
|
|
}
|
|
}
|
|
|
|
// -- Transaction history --
|
|
|
|
func handleGetTransactions(app core.App) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
accountId := e.Request.PathValue("id")
|
|
account, err := app.FindRecordById(collections.AccountCollectionName, accountId)
|
|
if err != nil {
|
|
return apis.NewNotFoundError("account not found", nil)
|
|
}
|
|
if account.GetString("owner") != e.Auth.Id {
|
|
return apis.NewForbiddenError("not your account", nil)
|
|
}
|
|
txs, err := app.FindRecordsByFilter(collections.TransactionCollectionName,
|
|
"account = {:accountId}", "-created", 100, 0,
|
|
map[string]any{"accountId": accountId})
|
|
if err != nil {
|
|
return apis.NewInternalServerError("", nil)
|
|
}
|
|
type tr struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Direction string `json:"direction"`
|
|
Amount float64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
Status string `json:"status"`
|
|
Reference string `json:"reference"`
|
|
Created string `json:"created"`
|
|
}
|
|
out := make([]tr, 0, len(txs))
|
|
for _, t := range txs {
|
|
out = append(out, tr{t.Id, t.GetString("type"), t.GetString("direction"),
|
|
t.GetFloat("amount"), t.GetString("currency"), t.GetString("status"),
|
|
t.GetString("reference"), t.GetString("created")})
|
|
}
|
|
return e.JSON(http.StatusOK, out)
|
|
}
|
|
}
|