Files
zeekayandHanzo Dev 452585e716 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:27:58 -07:00

336 lines
15 KiB
Go

package bank
import (
"errors"
"math"
"os"
"strconv"
"strings"
"github.com/hanzoai/base/core"
"github.com/luxfi/bank/collections"
)
// -----------------------------------------------------------------------------
// Custodian — who holds an account's on-chain assets, and who signs when they
// move.
//
// Two answers, chosen per deployment by BANK_CUSTODY. The default is the bank
// itself: it derives every customer's private key from a single mnemonic it
// keeps, so one compromised process is every customer at once. The other is
// `holder` — the account's owner keeps their own key and the bank keeps no key
// at all, which is what makes a deployment self-custodial rather than merely
// careful.
//
// Others differ only in who holds — a regulated custodian, the customer's own
// threshold nodes, a card holding the key in a secure element — and not at all
// in what the ledger needs from them. That is why one interface covers all of
// it, and why adding one is a file rather than a refactor.
//
// Every method names the ACCOUNT. None takes a derivation index, and that is
// the design rather than a preference: an index means something only to whoever
// holds the mnemonic it indexes, so a signature that accepts one has already
// answered the question. Exactly one function in the bank maps an account to an
// index — chainIndex, below — and only a custodian calls it.
//
// Reading the chain is not custody and is not here. Balances, token symbols and
// receipts belong to ChainBackend, keyed by an address, and stay legible no
// matter who holds the key.
// -----------------------------------------------------------------------------
type Custodian interface {
Name() string
// Holds says whether the addresses this custodian names are backed by a key
// somebody can sign with. It does NOT say who — that is Name, and the two
// are separate facts: the bank holding an account's key and the customer
// holding it are opposite custody postures that both answer true here.
//
// Only the simulation and an unimplemented custodian answer false. That is
// the whole point: an address nobody holds must never take the place of one
// somebody does, because coins sent to it are gone.
Holds() bool
// Wallet is what this custodian holds for the account in one asset.
Wallet(app core.App, acct *core.Record, asset string) Wallet
// Send moves the account's own holding to a destination and returns the
// hash once it has settled.
Send(app core.App, acct *core.Record, asset, to string, amount Minor) (string, error)
// Market is the account's access to an asset's collateral market: nil when
// there is none to reach and Earn belongs on the ledger, an error when the
// custodian cannot act for this account at all. Reporting the second as the
// first would credit a loan on the bank's own books that was meant to be a
// position somebody is holding collateral against.
Market(app core.App, acct *core.Record, asset string) (Market, error)
}
// Wallet is one account's holding in one asset as its custodian sees it: where
// it receives, and the reference the custodian knows it by — the handle an
// operator quotes when a deposit goes missing.
//
// The address is the dangerous field. A custodian that cannot name one leaves it
// empty, because an invented address points a customer at coins nobody will ever
// be able to spend.
type Wallet struct {
Address string
Ref string
}
// wallet is what a custodian holding the account's keys by index answers with.
// The reference is written here and nowhere else: it reaches the customer
// through GET /accounts/{id}/wallets and an operator quotes it back, so the two
// index-holding custodians cannot be allowed to drift into two shapes of it.
//
// It is named for the holder, and it used to read "mpc:" — a claim that the key
// was split across a threshold of parties, printed on a customer's own wallet
// row, while one process held the whole mnemonic. Nothing in this estate ever
// did threshold signing for a customer key. A reference that names the wrong
// holder is worse than none: it is the custody question answered wrongly, in
// the one place a customer looks.
func wallet(by, asset, index, address string) Wallet {
return Wallet{Address: address, Ref: by + ":" + asset + ":" + index}
}
// errUnheld says nobody the bank can name holds this account's keys — either the
// configured custodian is not one this build implements, or the account never
// claimed the identity the bank's own custody needs. Both ends are the same
// thing: no authority to move anything with, and refusing is the only answer
// that does not move money on a guess.
var errUnheld = errors.New("no custodian holds this account")
// custodian resolves who holds an account's on-chain assets.
//
// The sandbox stands in when no chain is configured at all: there is nothing to
// be custodian of. The predicate is the chain and not BANK_SANDBOX on purpose —
// a sandbox pointed at a real chain signs with real keys, and that is the demo
// everyone runs.
//
// BANK_CUSTODY names who holds, and the two names are the two custody postures:
// "bank" derives every key from the deploy mnemonic, "holder" keeps none and
// signs nothing. A name nobody implements gets nothing, because the
// alternative is a deployment that asked for the customer's Safe and quietly
// got the bank's key.
//
// The default is "bank", which is custodial, and it is the default because it
// is what the running deployments do. Changing the default would not move a
// single key; it would only make a live bank stop answering.
func custodian() Custodian {
if !ChainConfigured() {
return simCustodian{}
}
switch strings.ToLower(strings.TrimSpace(os.Getenv("BANK_CUSTODY"))) {
case "", "bank":
return deriving{}
case "holder":
return holder{}
default:
return unheld{}
}
}
// selfCustody reports whether this deployment leaves customers holding their
// own keys. It is the one deployment fact the API surface changes shape for:
// where the bank holds the key it derives the address and there is nothing for
// a customer to tell it, and where the customer holds it the bank has no
// address until they do.
//
// It is a function rather than a method because it is a question about the
// deployment, not about an account. Nothing per-request may answer it.
func selfCustody() bool {
_, own := custodian().(holder)
return own
}
// chainIndex is the account's place in the deploy mnemonic's derivation path,
// claimed on first use and stored so the account's address never moves. Index 0
// belongs to the bank's own treasury, which is why accounts start at 1.
//
// It lives with the custodians because it is not a fact about the account. It is
// a fact about who holds the account's keys, and it goes when they do.
func chainIndex(app core.App, acct *core.Record) string {
if n := int64(math.Round(acct.GetFloat("chainIndex"))); n > 0 {
return strconv.FormatInt(n, 10)
}
// Claiming an index is a read of the current maximum and then a write, so
// two accounts opened at the same moment both read the same number. The
// unique index on chainIndex is what settles it: one save wins, the other
// is refused and comes back here to take the next number. Without the
// retry a loser would keep the index it lost, and two customers would sign
// from one key, at one address, over one balance.
for range 16 {
next := int64(1)
if taken, _ := app.FindRecordsByFilter(collections.AccountCollectionName,
"chainIndex > 0", "-chainIndex", 1, 0, nil); len(taken) > 0 {
next = int64(math.Round(taken[0].GetFloat("chainIndex"))) + 1
}
acct.Set("chainIndex", next)
if err := app.Save(acct); err == nil {
return strconv.FormatInt(next, 10)
}
}
// Every attempt lost the race. Returning no index refuses the operation,
// which is the safe end: a wrong index would be a wrong key.
acct.Set("chainIndex", 0)
return ""
}
// -----------------------------------------------------------------------------
// deriving — the bank as custodian: one mnemonic, and every customer's key one
// hardened step off it at the index their account claimed.
//
// It is written down as an implementation rather than left as the shape of the
// code so that retiring it is a deletion, and so that a reader can see in one
// place what the bank currently is. The treasury is not here: that is the bank's
// own money, signed with the bank's own key, and it stays that way whoever comes
// to hold the customers'.
// -----------------------------------------------------------------------------
type deriving struct{}
func (deriving) Name() string { return "bank" }
func (deriving) Holds() bool { return true }
// Wallet: one chain, one address. Every asset the account holds arrives at the
// same place, so the asset only names the row — it will mean more to a custodian
// holding BTC on Bitcoin. The identity is claimed before the chain is consulted,
// so an account opened during an outage keeps the number it was given, and both
// custodians hand identities out in the same order.
func (deriving) Wallet(app core.App, acct *core.Record, asset string) Wallet {
i := chainIndex(app, acct)
if i == "" {
return Wallet{}
}
c := evm()
if c == nil {
return Wallet{}
}
return wallet(deriving{}.Name(), asset, i, c.address(i))
}
func (deriving) Send(app core.App, acct *core.Record, asset, to string, amount Minor) (string, error) {
c := evm()
if c == nil {
return "", errChainDown
}
return c.send(chainIndex(app, acct), asset, to, amount)
}
// Market asks the chain what it lends against before asking the account for an
// identity, so a vault the chain carries no market for costs the account
// nothing — it falls to the ledger, which is where it was always going.
//
// A chain that cannot be reached is the other empty answer and not the same
// one. Bank custody is only chosen when a chain is configured, so nothing to
// ask means an outage, and answering "no market" would hand the movement to the
// ledger: a borrow credited against collateral no chain is holding, sized
// against a position the chain overwrites as soon as it returns. Send refuses
// over an outage and so does this.
func (deriving) Market(app core.App, acct *core.Record, asset string) (Market, error) {
c := evm()
if c == nil {
return nil, errChainDown
}
if !c.lends(asset) {
return nil, nil
}
i := chainIndex(app, acct)
if i == "" {
return nil, errUnheld
}
return c.market(asset, i), nil
}
// -----------------------------------------------------------------------------
// simCustodian — the stand-in for a holder when there is no chain to hold
// anything on. Deterministic display addresses, a hash nothing broadcast, and no
// market. Safe only because it is reachable only with nothing configured at all.
// -----------------------------------------------------------------------------
type simCustodian struct{}
func (simCustodian) Name() string { return "sandbox" }
func (simCustodian) Holds() bool { return false }
func (simCustodian) Wallet(app core.App, acct *core.Record, asset string) Wallet {
i := chainIndex(app, acct)
if i == "" {
return Wallet{}
}
return wallet(simCustodian{}.Name(), asset, i, simAddress(i, asset))
}
func (simCustodian) Send(_ core.App, _ *core.Record, asset, _ string, _ Minor) (string, error) {
return txHashFor(asset), nil
}
func (simCustodian) Market(core.App, *core.Record, string) (Market, error) { return nil, nil }
// -----------------------------------------------------------------------------
// holder — the account's own address, and no key here at all.
//
// Named for the authority it leaves where it found it. Authority.sol in
// luxfi/standard separates HOLDER from REGISTER, and this is the first of the
// two: whoever holds the key. The second is the transfer agent's, it corrects
// the record rather than signing, and giving it up is not what non-custody
// means — see COMPLIANCE.md.
//
// The account records one address (accounts.address) and the bank reads it. It
// derives nothing, stores nothing, and cannot sign: what a customer holds, only
// a customer moves, from whatever holds their key — a Safe, a hardware wallet,
// their own threshold nodes. The bank keeps the ledger and the interface.
//
// The two refusals below are the posture, not a gap. There is no version of
// this custodian that signs, because the bank does not have the material to
// sign with; a deposit is the customer receiving at their own address, and a
// withdrawal is the customer spending from it. Earn is refused for the same
// reason — a vault movement is a transaction, and a transaction needs a signer.
//
// This is what footnote 6 of the April 2026 staff statement asks for: the
// provider has neither custody of, nor access to, the key. Not a share of it,
// not a recovery blob it can decrypt, not a derivation index into a mnemonic it
// keeps. The address is a public value and holding one is not custody.
// -----------------------------------------------------------------------------
type holder struct{}
func (holder) Name() string { return "holder" }
func (holder) Holds() bool { return true }
// Wallet is the address the account declared, the same one for every asset —
// an address is a chain fact, not an asset fact, and this custodian cannot
// invent a second one. An account that has declared nothing gets no address
// rather than a placeholder: there is no key behind a placeholder either.
//
// It carries no reference. The other two hand back an index into a mnemonic,
// which is a handle onto something the bank holds; here the address is the only
// handle there is, and repeating it under another name would suggest the bank
// knows something further about the holding. It does not.
func (holder) Wallet(_ core.App, acct *core.Record, _ string) Wallet {
return Wallet{Address: strings.TrimSpace(acct.GetString("address"))}
}
var errHolderSigns = errors.New("this account's key is held by its owner; the bank cannot sign for it")
func (holder) Send(core.App, *core.Record, string, string, Minor) (string, error) {
return "", errHolderSigns
}
func (holder) Market(core.App, *core.Record, string) (Market, error) {
return nil, errHolderSigns
}
// -----------------------------------------------------------------------------
// unheld — a custodian the bank was told to use and does not implement. It acts
// for nobody, and every operation says so.
// -----------------------------------------------------------------------------
type unheld struct{}
func (unheld) Name() string { return "none" }
func (unheld) Holds() bool { return false }
func (unheld) Wallet(core.App, *core.Record, string) Wallet { return Wallet{} }
func (unheld) Send(core.App, *core.Record, string, string, Minor) (string, error) {
return "", errUnheld
}
func (unheld) Market(core.App, *core.Record, string) (Market, error) { return nil, errUnheld }