diff --git a/cmd/wallet/main.go b/cmd/wallet/main.go index ec669b0..d60648b 100644 --- a/cmd/wallet/main.go +++ b/cmd/wallet/main.go @@ -27,6 +27,8 @@ import ( "os" "os/signal" "path/filepath" + "strconv" + "strings" "sync" "syscall" "time" @@ -46,7 +48,7 @@ const shutdownGrace = 5 * time.Second // Version is the wallet binary's release tag. Kept in sync with the // app_version field in manifest.json — `manifest_test.go` cross-checks // they agree, so a release without bumping both fails CI. -const Version = "0.3.1" +const Version = "0.3.2" func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) @@ -69,10 +71,10 @@ func run(ctx context.Context, args []string) error { idPath = fs.String("identity", defaultPath("identity.json"), "ed25519 identity file (created on first start)") mfPath = fs.String("manifest", "", "path to manifest.json; when set, key.sign:cap grants activate runtime spend caps") capState = fs.String("cap-state", "", "JSONL spend log; persists rolling-window cap state across wallet restarts so caps aren't bypassable by daemon-restart") - evmIDPath = fs.String("evm-identity", defaultPath("identity-evm.json"), "secp256k1 identity for EVM/USDC payments (created on first start)") - evmChain = fs.Uint64("evm-chain", evm.ChainBaseMainnet, "EVM chain ID (8453=Base, 1=Ethereum mainnet, 84532=Base Sepolia)") - evmRPC = fs.String("evm-rpc", "", "EVM JSON-RPC endpoint for balance reads; env PILOT_EVM_RPC also honoured. Empty leaves wallet.evm.balance saying 'no rpc'") - evmOff = fs.Bool("no-evm", false, "disable the wallet.evm.* methods entirely (no secp256k1 key created)") + evmIDPath = fs.String("evm-identity", defaultPath("identity-evm.json"), "secp256k1 identity for EVM/USDC payments (created on first start)") + evmChains = fs.String("evm-chains", "8453", "comma-separated EVM chain IDs to enable. First is primary (used when wallet.evm.* requests omit chain_id). Known: 1=Ethereum, 8453=Base, 137=Polygon, 84532=Base Sepolia. Example: 8453,1,137") + evmRPC = fs.String("evm-rpc", "", "PRIMARY chain's JSON-RPC endpoint. For per-chain endpoints use PILOT_EVM_RPC_ env vars (e.g. PILOT_EVM_RPC_137=https://polygon-rpc.com). Falls back to $PILOT_EVM_RPC for the primary chain.") + evmOff = fs.Bool("no-evm", false, "disable every wallet.evm.* method (no secp256k1 key created)") showVer = fs.Bool("version", false, "print version and exit") ) fs.SetOutput(os.Stderr) @@ -110,14 +112,18 @@ func run(ctx context.Context, args []string) error { }() // EVM side. Created when --no-evm is NOT passed AND the secp256k1 - // keyfile can be loaded or freshly generated. RPC is optional — - // without it the wallet still signs EIP-3009 authorizations, but - // wallet.evm.balance reports "no RPC configured" instead of a - // live on-chain read. + // keyfile can be loaded or freshly generated. // - // Disabling EVM is supported (--no-evm) so dev / test runs that - // don't need on-chain methods don't pay the cost of generating a - // secp256k1 keypair on first start. + // Multichain: --evm-chains is a comma-separated list. The first + // is the primary (used when wallet.evm.* requests omit chain_id). + // Each chain gets its own optional RPC endpoint: + // - primary chain uses --evm-rpc (or $PILOT_EVM_RPC if --evm-rpc + // is empty) + // - every other chain reads $PILOT_EVM_RPC_ + // + // Wallets without RPC for a chain still sign EIP-3009 + // authorizations correctly; wallet.evm.balance just reports + // rpc_enabled=false rather than a live read. var w *wallet.Wallet if *evmOff { w = wallet.New(wallet.Address(*addr), signer, store) @@ -126,22 +132,30 @@ func run(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("evm identity: %w", err) } - // --evm-rpc beats env so an operator can override per-app. - rpc := *evmRPC - if rpc == "" { - rpc = os.Getenv("PILOT_EVM_RPC") + ids, err := parseChainIDs(*evmChains) + if err != nil { + return fmt.Errorf("evm chains: %w", err) } - cfg := wallet.EVMConfig{ - Signer: evmSigner, - ChainID: *evmChain, - RPCEndpoint: rpc, + cfgs := make([]wallet.EVMConfig, len(ids)) + for i, id := range ids { + rpc := perChainRPC(id, i, *evmRPC) + cfgs[i] = wallet.EVMConfig{ + Signer: evmSigner, + ChainID: id, + RPCEndpoint: rpc, + } } - w, err = wallet.NewWithEVM(wallet.Address(*addr), signer, store, cfg) + w, err = wallet.NewWithEVMs(wallet.Address(*addr), signer, store, cfgs) if err != nil { return fmt.Errorf("evm wallet: %w", err) } - logger.Printf("evm: addr=%s chain=%d token=%s rpc=%v", - w.EVMAddress().Hex(), *evmChain, w.EVMToken().Hex(), rpc != "") + // One log line per chain so an operator inspecting the daemon + // log can confirm exactly which chains landed + whether each + // has live-balance access. + for _, id := range w.EVMChainIDs() { + logger.Printf("evm: addr=%s chain=%d token=%s rpc=%v", + w.EVMAddress().Hex(), id, w.EVMTokenFor(id).Hex(), w.HasEVMRPCFor(id)) + } } defer w.Close() @@ -299,3 +313,53 @@ func defaultPath(name string) string { } return filepath.Join(home, ".pilot", "apps", "io.pilot.wallet", name) } + +// parseChainIDs splits a comma-separated chain id string into a slice +// of uint64s. Whitespace is tolerated around the commas; an empty +// string is rejected because the caller already validated --no-evm +// wasn't passed. +func parseChainIDs(s string) ([]uint64, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, errors.New("empty chain list") + } + parts := strings.Split(s, ",") + out := make([]uint64, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + id, err := strconv.ParseUint(p, 10, 64) + if err != nil { + return nil, fmt.Errorf("chain id %q: %w", p, err) + } + out = append(out, id) + } + if len(out) == 0 { + return nil, errors.New("no chain ids parsed") + } + return out, nil +} + +// perChainRPC returns the JSON-RPC endpoint to use for chainID. +// idx is the chain's position in --evm-chains (0 = primary). Lookup +// order: +// - For the primary chain (idx==0): --evm-rpc beats $PILOT_EVM_RPC. +// - Every chain: PILOT_EVM_RPC_ if set, falling through to +// the primary lookup above when it isn't. +// +// Returning empty is fine — the wallet still signs without RPC; only +// wallet.evm.balance changes shape. +func perChainRPC(chainID uint64, idx int, primaryFlag string) string { + if v := os.Getenv(fmt.Sprintf("PILOT_EVM_RPC_%d", chainID)); v != "" { + return v + } + if idx == 0 { + if primaryFlag != "" { + return primaryFlag + } + return os.Getenv("PILOT_EVM_RPC") + } + return "" +} diff --git a/manifest.json b/manifest.json index d435b8b..161db52 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "id": "io.pilot.wallet", - "app_version": "0.3.1", + "app_version": "0.3.2", "manifest_version": 2, "binary": { "runtime": "go", @@ -22,6 +22,7 @@ "wallet.evm.balance", "wallet.evm.satisfy", "wallet.evm.verify", + "wallet.evm.chains", "wallet.hookPreSendMessage", "wallet.hookPostRecvMessage" ], diff --git a/pkg/evm/chains.go b/pkg/evm/chains.go index 4f34416..84175f2 100644 --- a/pkg/evm/chains.go +++ b/pkg/evm/chains.go @@ -11,15 +11,21 @@ const ( // ChainID values per chainlist.org. ChainEthereumMainnet uint64 = 1 ChainBaseMainnet uint64 = 8453 + ChainPolygonMainnet uint64 = 137 ChainBaseSepolia uint64 = 84532 ) // USDC contract addresses. Keep these as string constants and parse on // demand so the package has no init-time hard failure if hex parsing // somehow disagrees. +// +// Polygon: native Circle-issued USDC at 0x3c499c…, not the older +// bridged USDC.e at 0x2791bca… — only the native version exposes +// EIP-3009 transferWithAuthorization. const ( usdcEthereumMainnet = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" usdcBaseMainnet = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + usdcPolygonMainnet = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" usdcBaseSepolia = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" ) @@ -33,6 +39,8 @@ func USDCAddress(chainID uint64) (Address, error) { s = usdcEthereumMainnet case ChainBaseMainnet: s = usdcBaseMainnet + case ChainPolygonMainnet: + s = usdcPolygonMainnet case ChainBaseSepolia: s = usdcBaseSepolia default: @@ -41,6 +49,18 @@ func USDCAddress(chainID uint64) (Address, error) { return ParseAddress(s) } +// KnownChainIDs is the list of chain IDs the wallet recognises out of +// the box. Multichain wallet startup can iterate this to set up +// bindings for every supported chain. +func KnownChainIDs() []uint64 { + return []uint64{ + ChainEthereumMainnet, + ChainBaseMainnet, + ChainPolygonMainnet, + ChainBaseSepolia, + } +} + // USDCDomain returns the EIP-712 Domain for USDC on chainID, ready to // pass to EIP3009Digest. USDC's EIP-712 domain uses name="USD Coin" // and version="2" on every chain (verified against the deployed FiatToken @@ -117,6 +137,7 @@ var knownTokens = func() map[uint64]map[Address]KnownToken { }{ {ChainEthereumMainnet, usdcEthereumMainnet, KnownToken{"USDC", 6}}, {ChainBaseMainnet, usdcBaseMainnet, KnownToken{"USDC", 6}}, + {ChainPolygonMainnet, usdcPolygonMainnet, KnownToken{"USDC", 6}}, {ChainBaseSepolia, usdcBaseSepolia, KnownToken{"USDC", 6}}, {ChainEthereumMainnet, usdtEthereumMainnet, KnownToken{"USDT", 6}}, {ChainBaseMainnet, usdtBaseMainnet, KnownToken{"USDT", 6}}, diff --git a/pkg/wallet/hooks_evm.go b/pkg/wallet/hooks_evm.go index 013b4ec..c402371 100644 --- a/pkg/wallet/hooks_evm.go +++ b/pkg/wallet/hooks_evm.go @@ -6,6 +6,7 @@ import ( "fmt" "math/big" "net/http" + "sort" "time" "github.com/pilot-protocol/app-store/pkg/payment" @@ -49,25 +50,62 @@ type EVMConfig struct { } // NewWithEVM constructs a wallet with both the internal ledger -// (existing semantics) AND the EVM x402 binding enabled. +// (existing semantics) AND a single-chain EVM x402 binding enabled. +// Thin wrapper over NewWithEVMs for callers that only need one chain. func NewWithEVM(addr Address, signer Signer, store Store, cfg EVMConfig) (*Wallet, error) { - if cfg.Signer == nil { - return nil, errors.New("wallet: EVMConfig.Signer required") - } - method, err := evm.NewEVMMethod(cfg.Signer, cfg.ChainID, cfg.TokenOverride) - if err != nil { - return nil, fmt.Errorf("wallet: build EVMMethod: %w", err) - } - binding := &evmBinding{ - signer: cfg.Signer, - method: method, - chainID: cfg.ChainID, + return NewWithEVMs(addr, signer, store, []EVMConfig{cfg}) +} + +// NewWithEVMs is the multichain constructor. The same secp256k1 key +// (cfg.Signer) is reused across every entry — an EVM address is +// chain-agnostic, so reusing the key is the canonical pattern, not a +// security trade-off. Each entry produces its own evm.EVMMethod +// (bound to that chain's USDC contract + EIP-712 domain) and its own +// optional RPC client. +// +// The first entry becomes the "primary" chain — what HasEVM(), +// EVMAddress() (the parameterless legacy form), EVMChainID(), +// EVMToken(), and EVMBalance() (parameterless) report. Per-chain +// callers use the parameterised siblings (EVMAddressFor, EVMBalanceFor) +// or SatisfyEVM (which routes by contract.ChainID). +// +// Every cfg in the slice must carry the same Signer — wallet-wide +// signer uniqueness keeps the cap-bookkeeping single-threaded and the +// on-chain address reproducible across reboots. +func NewWithEVMs(addr Address, signer Signer, store Store, cfgs []EVMConfig) (*Wallet, error) { + if len(cfgs) == 0 { + return nil, errors.New("wallet: NewWithEVMs needs at least one EVMConfig") } - if cfg.RPCEndpoint != "" { - binding.rpc = evm.NewClient(cfg.RPCEndpoint, cfg.HTTPClient) + if cfgs[0].Signer == nil { + return nil, errors.New("wallet: EVMConfig[0].Signer required") } + primarySigner := cfgs[0].Signer w := New(addr, signer, store) - w.evm = binding + w.evmByChain = make(map[uint64]*evmBinding, len(cfgs)) + for i, cfg := range cfgs { + if cfg.Signer == nil { + return nil, fmt.Errorf("wallet: EVMConfig[%d].Signer required", i) + } + if cfg.Signer.Address() != primarySigner.Address() { + return nil, fmt.Errorf("wallet: EVMConfig[%d] uses a different signer than [0]; all chains must share one key", i) + } + method, err := evm.NewEVMMethod(cfg.Signer, cfg.ChainID, cfg.TokenOverride) + if err != nil { + return nil, fmt.Errorf("wallet: EVMConfig[%d]: %w", i, err) + } + b := &evmBinding{ + signer: cfg.Signer, + method: method, + chainID: cfg.ChainID, + } + if cfg.RPCEndpoint != "" { + b.rpc = evm.NewClient(cfg.RPCEndpoint, cfg.HTTPClient) + } + w.evmByChain[cfg.ChainID] = b + if i == 0 { + w.evm = b + } + } return w, nil } @@ -124,13 +162,85 @@ func (w *Wallet) EVMBalance(ctx context.Context) (*big.Int, error) { return w.evm.rpc.BalanceOf(ctx, w.evm.method.Token(), w.evm.signer.Address()) } -// HasEVMRPC reports whether an RPC endpoint was configured. Callers -// use this to tell "no balance because chain access disabled" apart -// from "no balance because the address actually holds nothing". +// HasEVMRPC reports whether an RPC endpoint was configured for the +// PRIMARY chain. The multichain sibling HasEVMRPCFor(chainID) checks +// a specific chain. func (w *Wallet) HasEVMRPC() bool { return w.evm != nil && w.evm.rpc != nil } +// EVMChainIDs returns every configured chain id, sorted ascending so +// `pilotctl appstore call wallet.evm.chains` produces stable output. +// nil/empty when no EVM bindings exist. +func (w *Wallet) EVMChainIDs() []uint64 { + if len(w.evmByChain) == 0 { + return nil + } + out := make([]uint64, 0, len(w.evmByChain)) + for id := range w.evmByChain { + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// HasEVMChain reports whether the wallet has a binding for chainID. +func (w *Wallet) HasEVMChain(chainID uint64) bool { + if w.evmByChain == nil { + return false + } + _, ok := w.evmByChain[chainID] + return ok +} + +// HasEVMRPCFor reports whether the per-chain RPC client is set. +func (w *Wallet) HasEVMRPCFor(chainID uint64) bool { + b := w.evmByChain[chainID] + return b != nil && b.rpc != nil +} + +// EVMTokenFor returns the ERC-20 contract address for the supplied +// chain (or the zero address if the chain isn't configured). +func (w *Wallet) EVMTokenFor(chainID uint64) evm.Address { + b := w.evmByChain[chainID] + if b == nil { + return evm.Address{} + } + return b.method.Token() +} + +// EVMBalanceFor reads the wallet's balance on chainID via that chain's +// RPC client. Returns (0, nil) if RPC isn't configured for that chain, +// (0, ErrEVMChainUnknown) if the chain isn't configured at all — the +// caller distinguishes "no RPC, but configured" from "this wallet +// doesn't speak that chain" via HasEVMChain. +func (w *Wallet) EVMBalanceFor(ctx context.Context, chainID uint64) (*big.Int, error) { + b := w.evmByChain[chainID] + if b == nil { + return big.NewInt(0), fmt.Errorf("wallet: chain %d not configured", chainID) + } + if b.rpc == nil { + return big.NewInt(0), nil + } + if ctx == nil { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + } + return b.rpc.BalanceOf(ctx, b.method.Token(), b.signer.Address()) +} + +// EVMMethodFor returns the per-chain payment.Method. nil for chains +// the wallet doesn't know about. Used by the IPC dispatcher when the +// caller specifies a chain_id. +func (w *Wallet) EVMMethodFor(chainID uint64) *evm.EVMMethod { + b := w.evmByChain[chainID] + if b == nil { + return nil + } + return b.method +} + // SatisfyEVM is the cap-aware entry point for producing an EIP-3009 // payment receipt against this wallet's EVM signer. It mirrors what // the manifest's `key.sign:evm-eip3009` grant constrains: the same @@ -146,15 +256,25 @@ func (w *Wallet) SatisfyEVM(ctx context.Context, c payment.Contract) (payment.Re if w.evm == nil { return payment.Receipt{}, errors.New("wallet.evm.satisfy: no EVM method bound") } - // Cap check + signing + record-on-success all happen under capMu - // to keep concurrent SatisfyEVM/Pay calls consistent against the - // shared spendLog (caps are wallet-wide, not per-surface). + return w.SatisfyEVMOn(ctx, w.evm.chainID, c) +} + +// SatisfyEVMOn is the multichain entry point. The caller passes the +// chain id explicitly — the IPC dispatcher exposes this as an +// optional `chain_id` field on wallet.evm.satisfy. Caps are checked +// against the same wallet-wide spendLog as SatisfyEVM and Pay so a +// multichain wallet can't dodge a cap by switching chains. +func (w *Wallet) SatisfyEVMOn(ctx context.Context, chainID uint64, c payment.Contract) (payment.Receipt, error) { + binding := w.evmByChain[chainID] + if binding == nil { + return payment.Receipt{}, fmt.Errorf("wallet.evm.satisfy: chain %d not configured", chainID) + } w.capMu.Lock() defer w.capMu.Unlock() if err := w.checkSpendCapLocked(Asset(c.Asset), Amount(c.Amount)); err != nil { return payment.Receipt{}, err } - receipt, err := w.evm.method.Satisfy(ctx, c) + receipt, err := binding.method.Satisfy(ctx, c) if err != nil { return payment.Receipt{}, err } diff --git a/pkg/wallet/wallet.go b/pkg/wallet/wallet.go index 70d1cb9..fbf280b 100644 --- a/pkg/wallet/wallet.go +++ b/pkg/wallet/wallet.go @@ -65,11 +65,21 @@ type Wallet struct { escrowOnce sync.Once escrow *WalletEscrow - // evm holds the (optional) on-chain wallet state. nil when the - // wallet was constructed without an EVM signer. Concrete type - // lives in hooks_evm.go to keep the chain-related code together. + // evm holds the (optional) primary on-chain wallet state. nil when + // the wallet was constructed without an EVM signer. The primary + // binding is reported by the parameterless legacy accessors + // (EVMAddress / EVMChainID / EVMBalance) so single-chain callers + // keep working unchanged. Multichain callers either iterate + // evmByChain or pass an explicit chainID to the parameterised + // siblings. Concrete type lives in hooks_evm.go. evm *evmBinding + // evmByChain is the multichain registry: chainID → binding. + // Always includes evm (the primary) when evm != nil. Nil when no + // EVM support is configured. Built once at construction in + // NewWithEVMs and read-only after that, so no mutex needed. + evmByChain map[uint64]*evmBinding + // Spend cap state — declared in spendcap.go, fields here for // embedding so the cap check + recordSpend can stay atomic via // one mutex. capMu guards both `caps` and `spendLog`. Both are diff --git a/pkg/walletipc/api.go b/pkg/walletipc/api.go index 2111220..2b1a88f 100644 --- a/pkg/walletipc/api.go +++ b/pkg/walletipc/api.go @@ -31,6 +31,7 @@ const ( MethodEVMBalance = "wallet.evm.balance" MethodEVMSatisfy = "wallet.evm.satisfy" MethodEVMVerify = "wallet.evm.verify" + MethodEVMChains = "wallet.evm.chains" ) // AllMethods is the canonical set the dispatcher registers. Used by @@ -45,7 +46,7 @@ var AllMethods = []string{ // wallet has EVM support. NewDispatcher returns AllMethods + (this set // when applicable). var AllEVMMethods = []string{ - MethodEVMAddress, MethodEVMBalance, MethodEVMSatisfy, MethodEVMVerify, + MethodEVMAddress, MethodEVMBalance, MethodEVMSatisfy, MethodEVMVerify, MethodEVMChains, } // ── balance ────────────────────────────────────────────────────────────── @@ -166,12 +167,26 @@ type SpendCapsResp struct { // constructed with NewWithEVM. On a wallet without an EVM binding, // these methods are absent — callers get a "method not found" reply. +// Multichain extension: every EVM request now accepts an optional +// `chain_id` field. Omitted (or 0) means "use the primary chain" +// (the first one configured at startup) — preserves the single-chain +// shape callers wrote against v0.3.1. Non-zero routes to that chain's +// binding, with a "chain X not configured" error when missing. + +type EVMAddressReq struct { + ChainID uint64 `json:"chain_id,omitempty"` +} + type EVMAddressResp struct { Address string `json:"address"` // 0x-prefixed lowercase hex ChainID uint64 `json:"chain_id"` // e.g. 8453 for Base mainnet Token string `json:"token"` // 0x-prefixed token contract address } +type EVMBalanceReq struct { + ChainID uint64 `json:"chain_id,omitempty"` +} + type EVMBalanceResp struct { Address string `json:"address"` ChainID uint64 `json:"chain_id"` @@ -183,7 +198,8 @@ type EVMBalanceResp struct { // EVMSatisfyReq wraps a payment.Contract verbatim. Receivers send this // to ask the wallet to produce a signed EIP-3009 receipt. type EVMSatisfyReq struct { - Contract any `json:"contract"` // payment.Contract — typed loosely here to avoid an import cycle; the handler deserializes properly. + ChainID uint64 `json:"chain_id,omitempty"` + Contract any `json:"contract"` // payment.Contract — typed loosely here to avoid an import cycle; the handler deserializes properly. } type EVMSatisfyResp struct { @@ -191,10 +207,27 @@ type EVMSatisfyResp struct { } type EVMVerifyReq struct { - Contract any `json:"contract"` // payment.Contract - Receipt any `json:"receipt"` // payment.Receipt + ChainID uint64 `json:"chain_id,omitempty"` + Contract any `json:"contract"` // payment.Contract + Receipt any `json:"receipt"` // payment.Receipt } type EVMVerifyResp struct { OK bool `json:"ok"` } + +// EVMChainsResp lists every chain this wallet is configured for. The +// `chains` array preserves the order configured at startup; the +// `primary` field repeats the first entry for callers that only need +// the default. Operators use this to discover what chain IDs the +// dispatcher accepts before sending a satisfy/balance request. +type EVMChainsResp struct { + Primary uint64 `json:"primary"` + Chains []ChainConfig `json:"chains"` +} + +type ChainConfig struct { + ChainID uint64 `json:"chain_id"` + Token string `json:"token"` + RPCEnabled bool `json:"rpc_enabled"` +} diff --git a/pkg/walletipc/dispatcher_evm.go b/pkg/walletipc/dispatcher_evm.go index 2956235..16805bd 100644 --- a/pkg/walletipc/dispatcher_evm.go +++ b/pkg/walletipc/dispatcher_evm.go @@ -17,6 +17,12 @@ import ( // surfaces transparently. // // Safe to call on a wallet without EVM support — it's a no-op. +// +// Multichain: every handler accepts an optional `chain_id` field on +// its request. Omitted (or 0) means "use the primary chain" — the +// first one passed to NewWithEVMs. A non-zero chain_id routes to that +// chain's binding, with a clean error if the wallet wasn't configured +// for it. wallet.evm.chains lets callers discover the set. func RegisterEVM(d *ipc.Dispatcher, w *wallet.Wallet) { if !w.HasEVM() { return @@ -25,30 +31,74 @@ func RegisterEVM(d *ipc.Dispatcher, w *wallet.Wallet) { d.Register(MethodEVMBalance, evmBalanceHandler(w)) d.Register(MethodEVMSatisfy, evmSatisfyHandler(w)) d.Register(MethodEVMVerify, evmVerifyHandler(w)) + d.Register(MethodEVMChains, evmChainsHandler(w)) +} + +// chainOrPrimary returns the requested chain id when non-zero, or the +// wallet's primary chain when zero. Tied to one helper so a future +// change (e.g. preferred-chain selection from spend caps) lives in +// one place. +func chainOrPrimary(w *wallet.Wallet, req uint64) uint64 { + if req != 0 { + return req + } + return w.EVMChainID() +} + +// decodeChainID extracts the optional chain_id field from a request +// payload without forcing a stricter typed Unmarshal — keeps the +// handler tolerant of clients that don't include the field. +func decodeChainID(payload json.RawMessage) (uint64, error) { + if len(payload) == 0 { + return 0, nil + } + var probe struct { + ChainID uint64 `json:"chain_id"` + } + if err := json.Unmarshal(payload, &probe); err != nil { + return 0, fmt.Errorf("decode chain_id: %w", err) + } + return probe.ChainID, nil } func evmAddressHandler(w *wallet.Wallet) ipc.Handler { - return func(_ context.Context, _ *ipc.Envelope) (json.RawMessage, error) { + return func(_ context.Context, env *ipc.Envelope) (json.RawMessage, error) { + reqChain, err := decodeChainID(env.Payload) + if err != nil { + return nil, err + } + chainID := chainOrPrimary(w, reqChain) + if !w.HasEVMChain(chainID) { + return nil, fmt.Errorf("wallet.evm.address: chain %d not configured", chainID) + } return encode(EVMAddressResp{ Address: w.EVMAddress().Hex(), - ChainID: w.EVMChainID(), - Token: w.EVMToken().Hex(), + ChainID: chainID, + Token: w.EVMTokenFor(chainID).Hex(), }) } } func evmBalanceHandler(w *wallet.Wallet) ipc.Handler { - return func(ctx context.Context, _ *ipc.Envelope) (json.RawMessage, error) { - balance, err := w.EVMBalance(ctx) + return func(ctx context.Context, env *ipc.Envelope) (json.RawMessage, error) { + reqChain, err := decodeChainID(env.Payload) + if err != nil { + return nil, err + } + chainID := chainOrPrimary(w, reqChain) + if !w.HasEVMChain(chainID) { + return nil, fmt.Errorf("wallet.evm.balance: chain %d not configured", chainID) + } + balance, err := w.EVMBalanceFor(ctx, chainID) if err != nil { return nil, fmt.Errorf("wallet.evm.balance: %w", err) } return encode(EVMBalanceResp{ Address: w.EVMAddress().Hex(), - ChainID: w.EVMChainID(), - Token: w.EVMToken().Hex(), + ChainID: chainID, + Token: w.EVMTokenFor(chainID).Hex(), Balance: balance.String(), - RPCEnabled: w.HasEVMRPC(), + RPCEnabled: w.HasEVMRPCFor(chainID), }) } } @@ -58,16 +108,17 @@ func evmSatisfyHandler(w *wallet.Wallet) ipc.Handler { // Decode the request as a typed payment.Contract — the api.go // shape uses `any` to avoid cycles, but we re-decode strictly here. var inner struct { + ChainID uint64 `json:"chain_id"` Contract payment.Contract `json:"contract"` } if err := json.Unmarshal(req.Payload, &inner); err != nil { - return nil, fmt.Errorf("decode contract: %w", err) + return nil, fmt.Errorf("decode satisfy req: %w", err) } - // Route through Wallet.SatisfyEVM so the same rolling-window - // spend cap that gates Pay also gates on-chain receipts. - // Calling EVMMethod().Satisfy directly bypasses the cap and - // is reserved for tests that explicitly want unchecked signing. - receipt, err := w.SatisfyEVM(ctx, inner.Contract) + chainID := chainOrPrimary(w, inner.ChainID) + // Route through Wallet.SatisfyEVMOn so the same rolling-window + // spend cap that gates Pay also gates on-chain receipts, even + // when the caller targets a non-primary chain. + receipt, err := w.SatisfyEVMOn(ctx, chainID, inner.Contract) if err != nil { return nil, err } @@ -80,13 +131,15 @@ func evmSatisfyHandler(w *wallet.Wallet) ipc.Handler { func evmVerifyHandler(w *wallet.Wallet) ipc.Handler { return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { var inner struct { + ChainID uint64 `json:"chain_id"` Contract payment.Contract `json:"contract"` Receipt payment.Receipt `json:"receipt"` } if err := json.Unmarshal(req.Payload, &inner); err != nil { return nil, fmt.Errorf("decode verify args: %w", err) } - method := w.EVMMethod() + chainID := chainOrPrimary(w, inner.ChainID) + method := w.EVMMethodFor(chainID) if method == nil { return nil, errors.New("wallet.evm.verify: no EVM method bound") } @@ -96,3 +149,21 @@ func evmVerifyHandler(w *wallet.Wallet) ipc.Handler { return encode(EVMVerifyResp{OK: true}) } } + +func evmChainsHandler(w *wallet.Wallet) ipc.Handler { + return func(_ context.Context, _ *ipc.Envelope) (json.RawMessage, error) { + ids := w.EVMChainIDs() + out := EVMChainsResp{ + Primary: w.EVMChainID(), + Chains: make([]ChainConfig, 0, len(ids)), + } + for _, id := range ids { + out.Chains = append(out.Chains, ChainConfig{ + ChainID: id, + Token: w.EVMTokenFor(id).Hex(), + RPCEnabled: w.HasEVMRPCFor(id), + }) + } + return encode(out) + } +}