diff --git a/cmd/wallet/main.go b/cmd/wallet/main.go index 0c34d6d..4ef77fe 100644 --- a/cmd/wallet/main.go +++ b/cmd/wallet/main.go @@ -19,6 +19,8 @@ package main import ( "context" + "crypto/ed25519" + "encoding/hex" "errors" "flag" "fmt" @@ -36,6 +38,7 @@ import ( "github.com/pilot-protocol/app-store/pkg/ipc" "github.com/pilot-protocol/app-store/pkg/manifest" "github.com/pilot-protocol/wallet/pkg/evm" + "github.com/pilot-protocol/wallet/pkg/settlerclient" "github.com/pilot-protocol/wallet/pkg/wallet" "github.com/pilot-protocol/wallet/pkg/walletipc" ) @@ -48,7 +51,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.3" +const Version = "0.4.0" func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) @@ -75,6 +78,8 @@ func run(ctx context.Context, args []string) error { evmChains = fs.String("evm-chains", "8453,1,137", "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. $PILOT_EVM_CHAINS overrides this when --evm-chains was left at the default.") 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)") + settlerAddr = fs.String("settler-addr", "", "TCP endpoint of the pilot-protocol/settler service (host:port). Empty disables wallet.settler.* methods. Env: PILOT_SETTLER_ADDR.") + settlerPubkeyHex = fs.String("settler-pubkey", "", "expected settler ed25519 pubkey (hex) — when set, the wallet refuses to start if the live settler advertises a different pubkey. Env: PILOT_SETTLER_PUBKEY.") showVer = fs.Bool("version", false, "print version and exit") ) fs.SetOutput(os.Stderr) @@ -208,8 +213,45 @@ func run(ctx context.Context, args []string) error { logger.Printf("spend caps: %d active from %s", len(caps), *mfPath) } + // Settler client. Configured via --settler-addr (or env + // PILOT_SETTLER_ADDR) with an optional trust anchor pubkey + // (--settler-pubkey / PILOT_SETTLER_PUBKEY). When the trust + // anchor is set, the wallet asks the settler for its identity at + // startup and refuses to run on a mismatch — catches a + // misconfigured or impersonating settler before any signed + // payload reaches it. + settlerEP := *settlerAddr + if settlerEP == "" { + settlerEP = os.Getenv("PILOT_SETTLER_ADDR") + } + if settlerEP != "" { + anchorHex := *settlerPubkeyHex + if anchorHex == "" { + anchorHex = os.Getenv("PILOT_SETTLER_PUBKEY") + } + var anchor ed25519.PublicKey + if anchorHex != "" { + anchorBytes, err := hex.DecodeString(anchorHex) + if err != nil { + return fmt.Errorf("settler-pubkey hex: %w", err) + } + if len(anchorBytes) != ed25519.PublicKeySize { + return fmt.Errorf("settler-pubkey length %d != %d", len(anchorBytes), ed25519.PublicKeySize) + } + anchor = ed25519.PublicKey(anchorBytes) + } + client := settlerclient.New(settlerEP, anchor) + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + if err := client.VerifyIdentity(probeCtx); err != nil { + cancel() + return fmt.Errorf("settler trust-anchor check failed: %w", err) + } + cancel() + w.SetSettler(client) + logger.Printf("settler: endpoint=%s anchor=%v", settlerEP, anchor != nil) + } + dispatcher := walletipc.NewDispatcher(w) - walletipc.RegisterEVM(dispatcher, w) // If a stale socket exists from a previous crash, drop it. unix sockets // can't be re-bound while the inode still exists. diff --git a/manifest.json b/manifest.json index 64bdb0a..5821d71 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "id": "io.pilot.wallet", - "app_version": "0.3.3", + "app_version": "0.4.0", "manifest_version": 2, "binary": { "runtime": "go", @@ -23,6 +23,10 @@ "wallet.evm.satisfy", "wallet.evm.verify", "wallet.evm.chains", + "wallet.settler.identity", + "wallet.settler.balance", + "wallet.settler.history", + "wallet.settler.transfer", "wallet.hookPreSendMessage", "wallet.hookPostRecvMessage" ], @@ -38,9 +42,13 @@ "if": {"kind": "rate", "params": {"per": "min", "limit": 100}}}, {"cap": "net.dial", "target": "*.base.org", "if": {"kind": "rate", "params": {"per": "min", "limit": 60}}}, + {"cap": "net.dial", "target": "settler:9100", + "if": {"kind": "rate", "params": {"per": "min", "limit": 120}}}, {"cap": "key.sign", "target": "x402-auth", "if": {"kind": "cap", "params": {"asset": "USDC", "per": "day", "limit": 100}}}, {"cap": "key.sign", "target": "evm-eip3009", + "if": {"kind": "cap", "params": {"asset": "USDC", "per": "day", "limit": 100}}}, + {"cap": "key.sign", "target": "settler-transfer", "if": {"kind": "cap", "params": {"asset": "USDC", "per": "day", "limit": 100}}} ], "protection": "guarded", diff --git a/manifest_test.go b/manifest_test.go index 1a41e23..4b1250a 100644 --- a/manifest_test.go +++ b/manifest_test.go @@ -160,10 +160,10 @@ func TestShippedManifestSpendCapsParse(t *testing.T) { raw, _ := os.ReadFile(manifestPath(t)) m, _ := manifest.Parse(raw) caps := wallet.ParseSpendCapsFromManifest(m.Grants) - if len(caps) != 2 { - t.Fatalf("parsed %d caps from shipped manifest, want 2 (x402-auth + evm-eip3009)", len(caps)) + if len(caps) != 3 { + t.Fatalf("parsed %d caps from shipped manifest, want 3 (x402-auth + evm-eip3009 + settler-transfer)", len(caps)) } - // Both caps target USDC, both window=24h, both limit=100 (per manifest.json). + // All caps target USDC, window=24h, limit=100 (per manifest.json). for i, c := range caps { if c.Asset != "USDC" { t.Errorf("caps[%d].Asset = %q, want USDC", i, c.Asset) @@ -175,14 +175,14 @@ func TestShippedManifestSpendCapsParse(t *testing.T) { t.Errorf("caps[%d].Limit = %d, want 100", i, c.Limit) } } - // The two grants target different sign-purposes; without the + // The grants target different sign-purposes; without the // Target field carried through, multi-target manifests render as // indistinguishable duplicates in any introspection UI. targets := map[string]bool{} for _, c := range caps { targets[c.Target] = true } - for _, want := range []string{"x402-auth", "evm-eip3009"} { + for _, want := range []string{"x402-auth", "evm-eip3009", "settler-transfer"} { if !targets[want] { t.Errorf("shipped manifest's caps missing target=%q (got targets=%v)", want, targets) } diff --git a/pkg/settlerclient/client.go b/pkg/settlerclient/client.go new file mode 100644 index 0000000..48337eb --- /dev/null +++ b/pkg/settlerclient/client.go @@ -0,0 +1,303 @@ +// Package settlerclient is the wallet's thin client for the +// pilot-protocol/settler credit-ledger service. +// +// The settler holds the canonical balances. A wallet that wants to: +// +// - move credits to another agent → produce a signed TransferAuth +// locally with its own ed25519 key, submit to the settler over +// TCP, wait for the transaction id. +// - read its own balance → ledger.balance over the same TCP socket. +// - see history → ledger.history. +// +// The client speaks the same length-prefixed JSON envelope used by +// app-store/pkg/ipc (see ipc.WriteFrame / ReadFrame). One client +// instance is one short-lived TCP connection per call — simple, +// matches the IPC dispatcher's connection-per-request behaviour, and +// keeps TLS bringup straightforward to add later. +// +// Trust: +// +// - settlerPubkey is the embedded trust anchor. The client doesn't +// yet verify settler-signed receipts (the v1 settler doesn't sign +// responses), but the pubkey lets a caller assert "I'm talking to +// the settler I expected" via ledger.identity at dial time. +// - The settler verifies the payer's signature on every transfer, +// so a mitm can't forge a transfer from your account without +// your private key. +package settlerclient + +import ( + "context" + "crypto/ed25519" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "time" +) + +// Client targets one settler endpoint. Safe for concurrent use — +// each call dials a fresh TCP connection. +type Client struct { + endpoint string // e.g. "136.113.97.205:9100" + settlerPub ed25519.PublicKey + dialTimeout time.Duration + callTimeout time.Duration +} + +// New constructs a client. endpoint must be host:port. settlerPub +// can be nil; when set, VerifyIdentity is the helper that asserts +// the live settler advertises the same pubkey we were configured for. +func New(endpoint string, settlerPub ed25519.PublicKey) *Client { + return &Client{ + endpoint: endpoint, + settlerPub: settlerPub, + dialTimeout: 3 * time.Second, + callTimeout: 8 * time.Second, + } +} + +// Endpoint returns the configured TCP endpoint. +func (c *Client) Endpoint() string { return c.endpoint } + +// Identity calls ledger.identity. Returns the settler's advertised +// pubkey, the operator status, and (when present) the operator's +// pubkey. Wallets call this once at startup to verify the trust +// anchor matches. +func (c *Client) Identity(ctx context.Context) (IdentityResp, error) { + var out IdentityResp + if err := c.call(ctx, "ledger.identity", nil, &out); err != nil { + return IdentityResp{}, err + } + return out, nil +} + +// VerifyIdentity returns nil when the configured settler endpoint +// advertises the embedded trust anchor pubkey. Called once at +// wallet startup to surface a misconfiguration loudly. +func (c *Client) VerifyIdentity(ctx context.Context) error { + if len(c.settlerPub) != ed25519.PublicKeySize { + return nil // no trust anchor configured — caller opts in + } + resp, err := c.Identity(ctx) + if err != nil { + return fmt.Errorf("settler identity probe: %w", err) + } + pub, err := hex.DecodeString(resp.SettlerPubkey) + if err != nil { + return fmt.Errorf("settler returned malformed pubkey hex: %w", err) + } + if !equalPubkey(ed25519.PublicKey(pub), c.settlerPub) { + return fmt.Errorf("settler pubkey mismatch: anchor=%s live=%s", + hex.EncodeToString(c.settlerPub), resp.SettlerPubkey) + } + return nil +} + +// Balance returns the account's current balance for the asset. A +// missing-account error is NOT raised here — the settler returns +// amount=0 for unknown accounts so wallets can poll without +// special-casing first-touch users. +func (c *Client) Balance(ctx context.Context, account ed25519.PublicKey, asset string) (uint64, error) { + req := BalanceReq{Account: hex.EncodeToString(account), Asset: asset} + var out BalanceResp + if err := c.call(ctx, "ledger.balance", req, &out); err != nil { + return 0, err + } + return out.Amount, nil +} + +// History returns up to `limit` transactions touching the account. +// Pass limit=0 for the settler's default (100). +func (c *Client) History(ctx context.Context, account ed25519.PublicKey, limit int) ([]Transaction, error) { + req := HistoryReq{Account: hex.EncodeToString(account), Limit: limit} + var out HistoryResp + if err := c.call(ctx, "ledger.history", req, &out); err != nil { + return nil, err + } + return out.Transactions, nil +} + +// Transfer signs a TransferAuth with `signer` and submits it. The +// signer must be the ed25519 key whose pubkey is in `from` — the +// settler verifies the signature against the from-key, so passing +// the wrong signer will fail at the settler with ErrBadSignature. +func (c *Client) Transfer( + ctx context.Context, + signer Signer, + to ed25519.PublicKey, + asset string, + amount uint64, + memo string, + expiresIn time.Duration, +) (Transaction, error) { + if len(to) != ed25519.PublicKeySize { + return Transaction{}, errors.New("settler client: to must be a 32-byte ed25519 pubkey") + } + if amount == 0 { + return Transaction{}, errors.New("settler client: amount must be > 0") + } + nonce, err := randomNonce() + if err != nil { + return Transaction{}, err + } + if expiresIn <= 0 { + expiresIn = 5 * time.Minute + } + expiresAt := time.Now().Add(expiresIn).UTC() + from := signer.PublicKey() + payload := canonicalTransferPayload(from, to, asset, amount, nonce, expiresAt) + sig, err := signer.Sign(payload) + if err != nil { + return Transaction{}, fmt.Errorf("sign transfer: %w", err) + } + auth := TransferAuth{ + PayerPubkey: hex.EncodeToString(from), + To: hex.EncodeToString(to), + Asset: asset, + Amount: amount, + Nonce: hex.EncodeToString(nonce), + ExpiresAt: expiresAt, + Signature: hex.EncodeToString(sig), + } + var out TransferResp + if err := c.call(ctx, "ledger.transfer", TransferReq{Auth: auth}, &out); err != nil { + return Transaction{}, err + } + return out.Transaction, nil +} + +// canonicalTransferPayload reproduces the byte layout the settler's +// ledger.TransferAuth.SigningPayload builds. Must stay in lockstep +// with settler/pkg/ledger/signing.go — any field-order or framing +// change there has to land here too. The format is intentionally +// language-agnostic (length-prefixed strings + big-endian integers) +// so a Python or Swift client can reproduce it from first principles. +func canonicalTransferPayload(payerKey, toKey []byte, asset string, amount uint64, nonce []byte, expiresAt time.Time) []byte { + buf := make([]byte, 0, 256) + buf = append(buf, []byte("settler.transfer")...) + buf = append(buf, 0) + buf = append(buf, payerKey...) + buf = append(buf, toKey...) + buf = appendLP(buf, []byte(asset)) + var amt [8]byte + binary.BigEndian.PutUint64(amt[:], amount) + buf = append(buf, amt[:]...) + buf = appendLP(buf, []byte(hex.EncodeToString(nonce))) + var exp [8]byte + binary.BigEndian.PutUint64(exp[:], uint64(expiresAt.Unix())) + buf = append(buf, exp[:]...) + return buf +} + +func appendLP(dst, b []byte) []byte { + var ln [2]byte + binary.BigEndian.PutUint16(ln[:], uint16(len(b))) + dst = append(dst, ln[:]...) + return append(dst, b...) +} + +func equalPubkey(a, b ed25519.PublicKey) bool { + if len(a) != len(b) { + return false + } + var x byte + for i := range a { + x |= a[i] ^ b[i] + } + return x == 0 +} + +// ── transport ───────────────────────────────────────────────────────── + +// envelope matches the wire shape ipc.Envelope marshals to. We don't +// import app-store/pkg/ipc here to keep the client free of the +// dispatcher's plumbing — the wire format is the only thing we share. +type envelope struct { + Type string `json:"type"` + ReqID string `json:"req_id"` + Method string `json:"method"` + Payload json.RawMessage `json:"payload,omitempty"` + Error string `json:"error,omitempty"` +} + +// call dials, writes the request frame, reads the reply, decodes the +// payload into out (when non-nil). Errors get surfaced verbatim from +// the settler's `error` field; transport errors are wrapped. +func (c *Client) call(ctx context.Context, method string, args, out any) error { + deadline := time.Now().Add(c.callTimeout) + if ctxDl, ok := ctx.Deadline(); ok && ctxDl.Before(deadline) { + deadline = ctxDl + } + + dialer := &net.Dialer{Timeout: c.dialTimeout} + conn, err := dialer.DialContext(ctx, "tcp", c.endpoint) + if err != nil { + return fmt.Errorf("settler dial %s: %w", c.endpoint, err) + } + defer conn.Close() + _ = conn.SetDeadline(deadline) + + var payload json.RawMessage + if args != nil { + b, err := json.Marshal(args) + if err != nil { + return fmt.Errorf("marshal %s args: %w", method, err) + } + payload = b + } + req := envelope{Type: "req", ReqID: newReqID(), Method: method, Payload: payload} + if err := writeFrame(conn, req); err != nil { + return fmt.Errorf("settler write: %w", err) + } + resp, err := readFrame(conn) + if err != nil { + return fmt.Errorf("settler read: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("settler: %s", resp.Error) + } + if out != nil && len(resp.Payload) > 0 { + if err := json.Unmarshal(resp.Payload, out); err != nil { + return fmt.Errorf("decode %s response: %w", method, err) + } + } + return nil +} + +func writeFrame(w io.Writer, env envelope) error { + body, err := json.Marshal(env) + if err != nil { + return err + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + _, err = w.Write(body) + return err +} + +func readFrame(r io.Reader) (envelope, error) { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return envelope{}, err + } + n := binary.BigEndian.Uint32(hdr[:]) + if n == 0 || n > 1<<20 { + return envelope{}, fmt.Errorf("settler client: bad frame length %d", n) + } + body := make([]byte, n) + if _, err := io.ReadFull(r, body); err != nil { + return envelope{}, err + } + var env envelope + if err := json.Unmarshal(body, &env); err != nil { + return envelope{}, err + } + return env, nil +} diff --git a/pkg/settlerclient/nonce.go b/pkg/settlerclient/nonce.go new file mode 100644 index 0000000..3af5c02 --- /dev/null +++ b/pkg/settlerclient/nonce.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package settlerclient + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +// randomNonce returns 16 random bytes. The settler's nonce dedupe +// table is keyed on the hex string, so any high-entropy 16-byte +// value works. 128 bits ≫ birthday paradox over realistic transfer +// volumes. +func randomNonce() ([]byte, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return nil, fmt.Errorf("settler client: nonce: %w", err) + } + return b[:], nil +} + +// newReqID returns a short opaque request id for envelope tagging. +// The server echoes it back; the client doesn't track it across +// requests (one connection = one request), so any unique value +// works. +func newReqID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} diff --git a/pkg/settlerclient/types.go b/pkg/settlerclient/types.go new file mode 100644 index 0000000..6d6f9c4 --- /dev/null +++ b/pkg/settlerclient/types.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package settlerclient + +import "time" + +// Signer is the minimal interface the client needs from the wallet's +// signing primitive. wallet.Wallet's Signer field already matches it, +// so a wallet that wants to relay through the settler does +// `client.Transfer(ctx, walletSigner, ...)` directly. +type Signer interface { + PublicKey() []byte + Sign(msg []byte) ([]byte, error) +} + +// Wire shapes mirror settler/pkg/settlerapi/api.go. Duplicated here +// to keep the client package free of a hard dependency on the +// settler repo — adding a go.mod require for it would couple every +// wallet build to the settler's release cadence. + +type IdentityResp struct { + SettlerPubkey string `json:"settler_pubkey"` + HasOperator bool `json:"has_operator"` + OperatorPubkey string `json:"operator_pubkey,omitempty"` +} + +type BalanceReq struct { + Account string `json:"account"` + Asset string `json:"asset"` +} + +type BalanceResp struct { + Account string `json:"account"` + Asset string `json:"asset"` + Amount uint64 `json:"amount"` +} + +type TransferAuth struct { + PayerPubkey string `json:"payer_pubkey"` + To string `json:"to"` + Asset string `json:"asset"` + Amount uint64 `json:"amount"` + Nonce string `json:"nonce"` + ExpiresAt time.Time `json:"expires_at"` + Signature string `json:"signature"` +} + +type TransferReq struct { + Auth TransferAuth `json:"auth"` +} + +type TransferResp struct { + Transaction Transaction `json:"transaction"` +} + +type HistoryReq struct { + Account string `json:"account"` + Limit int `json:"limit,omitempty"` +} + +type HistoryResp struct { + Transactions []Transaction `json:"transactions"` +} + +// Transaction is one row of the settler's audit log. +type Transaction struct { + ID string `json:"id"` + Kind string `json:"kind"` + Asset string `json:"asset"` + Amount uint64 `json:"amount"` + From string `json:"from,omitempty"` + To string `json:"to"` + Nonce string `json:"nonce,omitempty"` + Timestamp time.Time `json:"timestamp"` + OperatorOK bool `json:"operator_ok,omitempty"` +} diff --git a/pkg/wallet/hooks_settler.go b/pkg/wallet/hooks_settler.go new file mode 100644 index 0000000..37e856b --- /dev/null +++ b/pkg/wallet/hooks_settler.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package wallet + +import ( + "context" + "errors" + "time" + + "github.com/pilot-protocol/wallet/pkg/settlerclient" +) + +// SetSettler installs a settler client on the wallet. Called once +// at startup; passing nil disables every wallet.settler.* method. +// The wallet does NOT take ownership of the client beyond holding a +// reference — concurrent use across goroutines is safe because +// the settlerclient.Client opens a fresh TCP connection per call. +func (w *Wallet) SetSettler(c *settlerclient.Client) { + w.settler = c +} + +// HasSettler reports whether the wallet was configured with a +// settler client. +func (w *Wallet) HasSettler() bool { return w.settler != nil } + +// Settler returns the configured client (or nil). +func (w *Wallet) Settler() *settlerclient.Client { return w.settler } + +// SettlerBalance returns this wallet's own balance at the settler. +// The wallet's ed25519 pubkey identifies the account; assetSymbol +// (e.g. "USDC") selects the per-asset ledger. +func (w *Wallet) SettlerBalance(ctx context.Context, assetSymbol string) (uint64, error) { + if w.settler == nil { + return 0, errors.New("wallet.settler: no settler configured") + } + return w.settler.Balance(ctx, w.signer.PublicKey(), assetSymbol) +} + +// SettlerHistory returns this wallet's recent ledger activity. +// Passing limit=0 falls back to the settler's default (100). +func (w *Wallet) SettlerHistory(ctx context.Context, limit int) ([]settlerclient.Transaction, error) { + if w.settler == nil { + return nil, errors.New("wallet.settler: no settler configured") + } + return w.settler.History(ctx, w.signer.PublicKey(), limit) +} + +// SettlerTransfer signs a TransferAuth with this wallet's key and +// submits it to the settler. The settler verifies the signature +// against this wallet's pubkey before debiting. +func (w *Wallet) SettlerTransfer( + ctx context.Context, + to []byte, + asset string, + amount uint64, + memo string, + expiresIn time.Duration, +) (settlerclient.Transaction, error) { + if w.settler == nil { + return settlerclient.Transaction{}, errors.New("wallet.settler: no settler configured") + } + a := Asset(asset) + amt := Amount(amount) + w.capMu.Lock() + defer w.capMu.Unlock() + if err := w.checkSpendCapLocked(a, amt); err != nil { + return settlerclient.Transaction{}, err + } + tx, err := w.settler.Transfer(ctx, w.signer, to, asset, amount, memo, expiresIn) + if err != nil { + return settlerclient.Transaction{}, err + } + w.recordSpendLocked(a, amt) + return tx, nil +} + +// SettlerIdentity asks the settler for its self-declared identity. +func (w *Wallet) SettlerIdentity(ctx context.Context) (settlerclient.IdentityResp, error) { + if w.settler == nil { + return settlerclient.IdentityResp{}, errors.New("wallet.settler: no settler configured") + } + return w.settler.Identity(ctx) +} + +// SignerPublicKey returns the wallet's own ed25519 pubkey. Used by +// the IPC dispatcher when constructing settler queries — the wallet +// is its pubkey, so callers shouldn't have to thread it through +// every request. +func (w *Wallet) SignerPublicKey() []byte { + return w.signer.PublicKey() +} diff --git a/pkg/wallet/wallet.go b/pkg/wallet/wallet.go index 39c6abd..27969da 100644 --- a/pkg/wallet/wallet.go +++ b/pkg/wallet/wallet.go @@ -11,6 +11,8 @@ import ( "sort" "sync" "time" + + "github.com/pilot-protocol/wallet/pkg/settlerclient" ) // ErrInsufficientBalance is returned when a Pay would drive the balance below zero. @@ -80,6 +82,13 @@ type Wallet struct { // NewWithEVMs and read-only after that, so no mutex needed. evmByChain map[uint64]*evmBinding + // settler is the (optional) client for the canonical credit- + // ledger service. nil = wallet runs in local-only mode (the + // existing internal sqlite ledger is authoritative for "balance" + // / "pay" / "settle"). When set, wallet.settler.* IPC methods + // route their queries + signed transfers through this client. + settler *settlerclient.Client + // 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/wallet/zz_settler_cap_test.go b/pkg/wallet/zz_settler_cap_test.go new file mode 100644 index 0000000..3a0e6b7 --- /dev/null +++ b/pkg/wallet/zz_settler_cap_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package wallet + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "errors" + "net" + "testing" + "time" + + "github.com/pilot-protocol/wallet/pkg/settlerclient" +) + +func TestSettlerTransferHonorsSpendCap(t *testing.T) { + s, _ := NewLocalSigner() + w := NewInMemory(addrBob, s) + defer w.Close() + + now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) + w.clock = func() time.Time { return now } + w.SetSpendCaps(SpendCap{Asset: "USDC", Limit: 100, Window: 24 * time.Hour}) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + spub, _, _ := ed25519.GenerateKey(rand.Reader) + w.SetSettler(settlerclient.New(addr, spub)) + + to := make([]byte, ed25519.PublicKeySize) + ctx := context.Background() + + _, err = w.SettlerTransfer(ctx, to, "USDC", 150, "", 0) + if !errors.Is(err, ErrSpendCapExceeded) { + t.Fatalf("over-cap transfer err = %v, want ErrSpendCapExceeded", err) + } + if used := w.SpentInWindow("USDC", 24*time.Hour); used != 0 { + t.Fatalf("cap-rejected transfer consumed budget: used=%d, want 0", used) + } + + _, err = w.SettlerTransfer(ctx, to, "USDC", 50, "", 0) + if err == nil { + t.Fatal("transfer to a closed settler succeeded, want a network error") + } + if errors.Is(err, ErrSpendCapExceeded) { + t.Fatalf("within-cap transfer wrongly hit the cap: %v", err) + } + if used := w.SpentInWindow("USDC", 24*time.Hour); used != 0 { + t.Fatalf("failed transfer consumed budget: used=%d, want 0", used) + } +} diff --git a/pkg/walletipc/api.go b/pkg/walletipc/api.go index 2b1a88f..79875ca 100644 --- a/pkg/walletipc/api.go +++ b/pkg/walletipc/api.go @@ -32,6 +32,14 @@ const ( MethodEVMSatisfy = "wallet.evm.satisfy" MethodEVMVerify = "wallet.evm.verify" MethodEVMChains = "wallet.evm.chains" + + // Settler surface — only registered when the wallet was wired + // with a settler client at startup. Routes IPC calls through + // pkg/settlerclient against the configured TCP endpoint. + MethodSettlerIdentity = "wallet.settler.identity" + MethodSettlerBalance = "wallet.settler.balance" + MethodSettlerHistory = "wallet.settler.history" + MethodSettlerTransfer = "wallet.settler.transfer" ) // AllMethods is the canonical set the dispatcher registers. Used by @@ -49,6 +57,14 @@ var AllEVMMethods = []string{ MethodEVMAddress, MethodEVMBalance, MethodEVMSatisfy, MethodEVMVerify, MethodEVMChains, } +// AllSettlerMethods is the wallet-side settler surface. Like AllEVMMethods, +// these are registered conditionally — a wallet started without a +// settler endpoint omits them so callers get "method not found" +// rather than a misleading wire-error from a half-configured client. +var AllSettlerMethods = []string{ + MethodSettlerIdentity, MethodSettlerBalance, MethodSettlerHistory, MethodSettlerTransfer, +} + // ── balance ────────────────────────────────────────────────────────────── type BalanceReq struct { @@ -231,3 +247,61 @@ type ChainConfig struct { Token string `json:"token"` RPCEnabled bool `json:"rpc_enabled"` } + +// ── settler ────────────────────────────────────────────────────────────── +// +// IPC request/response types for wallet.settler.*. The wallet's own +// pubkey is implicit: every method operates against the wallet's +// signer, so callers never specify "account" — the wallet uses its +// own. + +type SettlerIdentityReq struct{} + +type SettlerIdentityResp struct { + Endpoint string `json:"endpoint"` + SettlerPubkey string `json:"settler_pubkey"` + HasOperator bool `json:"has_operator"` + OperatorPubkey string `json:"operator_pubkey,omitempty"` +} + +type SettlerBalanceReq struct { + Asset string `json:"asset"` +} + +type SettlerBalanceResp struct { + Account string `json:"account"` // wallet's own ed25519 pubkey, hex + Asset string `json:"asset"` + Amount uint64 `json:"amount"` +} + +type SettlerHistoryReq struct { + Limit int `json:"limit,omitempty"` +} + +type SettlerHistoryResp struct { + Transactions []SettlerTransaction `json:"transactions"` +} + +type SettlerTransaction struct { + ID string `json:"id"` + Kind string `json:"kind"` + Asset string `json:"asset"` + Amount uint64 `json:"amount"` + From string `json:"from,omitempty"` + To string `json:"to"` + Nonce string `json:"nonce,omitempty"` + Timestamp string `json:"timestamp"` // RFC3339 + OperatorOK bool `json:"operator_ok,omitempty"` +} + +type SettlerTransferReq struct { + To string `json:"to"` // hex ed25519 pubkey + Asset string `json:"asset"` // e.g. "USDC" + Amount uint64 `json:"amount"` // smallest unit + Memo string `json:"memo,omitempty"` + ExpiresInSeconds int64 `json:"expires_in_seconds,omitempty"` // 0 → 5 min default +} + +type SettlerTransferResp struct { + Transaction SettlerTransaction `json:"transaction"` +} diff --git a/pkg/walletipc/dispatcher.go b/pkg/walletipc/dispatcher.go index b219cc8..388f51f 100644 --- a/pkg/walletipc/dispatcher.go +++ b/pkg/walletipc/dispatcher.go @@ -29,6 +29,7 @@ func NewDispatcher(w *wallet.Wallet) *ipc.Dispatcher { d.Register(MethodTopup, topupHandler(w)) d.Register(MethodHistory, historyHandler(w)) RegisterEVM(d, w) + RegisterSettler(d, w) return d } diff --git a/pkg/walletipc/dispatcher_settler.go b/pkg/walletipc/dispatcher_settler.go new file mode 100644 index 0000000..279923d --- /dev/null +++ b/pkg/walletipc/dispatcher_settler.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package walletipc + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" + "github.com/pilot-protocol/wallet/pkg/wallet" +) + +// RegisterSettler adds the wallet.settler.* methods to an existing +// dispatcher when w has a settler client wired. Safe to call on a +// wallet without a settler — it's a no-op. +// +// The wallet's own ed25519 pubkey is implicit on every call: the +// settler keys accounts by pubkey, the wallet IS its pubkey, so +// callers never thread an "account" parameter through. +func RegisterSettler(d *ipc.Dispatcher, w *wallet.Wallet) { + if !w.HasSettler() { + return + } + d.Register(MethodSettlerIdentity, settlerIdentityHandler(w)) + d.Register(MethodSettlerBalance, settlerBalanceHandler(w)) + d.Register(MethodSettlerHistory, settlerHistoryHandler(w)) + d.Register(MethodSettlerTransfer, settlerTransferHandler(w)) +} + +func settlerIdentityHandler(w *wallet.Wallet) ipc.Handler { + return func(ctx context.Context, _ *ipc.Envelope) (json.RawMessage, error) { + id, err := w.SettlerIdentity(ctx) + if err != nil { + return nil, fmt.Errorf("wallet.settler.identity: %w", err) + } + ep := "" + if c := w.Settler(); c != nil { + ep = c.Endpoint() + } + return encode(SettlerIdentityResp{ + Endpoint: ep, + SettlerPubkey: id.SettlerPubkey, + HasOperator: id.HasOperator, + OperatorPubkey: id.OperatorPubkey, + }) + } +} + +func settlerBalanceHandler(w *wallet.Wallet) ipc.Handler { + return func(ctx context.Context, env *ipc.Envelope) (json.RawMessage, error) { + var req SettlerBalanceReq + if len(env.Payload) > 0 { + if err := json.Unmarshal(env.Payload, &req); err != nil { + return nil, fmt.Errorf("decode req: %w", err) + } + } + if req.Asset == "" { + return nil, errors.New("wallet.settler.balance: asset required") + } + amt, err := w.SettlerBalance(ctx, req.Asset) + if err != nil { + return nil, err + } + return encode(SettlerBalanceResp{ + Account: hex.EncodeToString(w.SignerPublicKey()), + Asset: req.Asset, + Amount: amt, + }) + } +} + +func settlerHistoryHandler(w *wallet.Wallet) ipc.Handler { + return func(ctx context.Context, env *ipc.Envelope) (json.RawMessage, error) { + var req SettlerHistoryReq + if len(env.Payload) > 0 { + _ = json.Unmarshal(env.Payload, &req) + } + rows, err := w.SettlerHistory(ctx, req.Limit) + if err != nil { + return nil, err + } + out := SettlerHistoryResp{ + Transactions: make([]SettlerTransaction, len(rows)), + } + for i, r := range rows { + out.Transactions[i] = SettlerTransaction{ + ID: r.ID, + Kind: r.Kind, + Asset: r.Asset, + Amount: r.Amount, + From: r.From, + To: r.To, + Nonce: r.Nonce, + Timestamp: r.Timestamp.Format(time.RFC3339Nano), + OperatorOK: r.OperatorOK, + } + } + return encode(out) + } +} + +func settlerTransferHandler(w *wallet.Wallet) ipc.Handler { + return func(ctx context.Context, env *ipc.Envelope) (json.RawMessage, error) { + var req SettlerTransferReq + if err := json.Unmarshal(env.Payload, &req); err != nil { + return nil, fmt.Errorf("decode req: %w", err) + } + if req.Asset == "" || req.Amount == 0 || req.To == "" { + return nil, errors.New("wallet.settler.transfer: to, asset and amount > 0 required") + } + toBytes, err := hex.DecodeString(req.To) + if err != nil { + return nil, fmt.Errorf("to: %w", err) + } + var expiresIn time.Duration + if req.ExpiresInSeconds > 0 { + expiresIn = time.Duration(req.ExpiresInSeconds) * time.Second + } + tx, err := w.SettlerTransfer(ctx, toBytes, req.Asset, req.Amount, req.Memo, expiresIn) + if err != nil { + return nil, err + } + return encode(SettlerTransferResp{ + Transaction: SettlerTransaction{ + ID: tx.ID, + Kind: tx.Kind, + Asset: tx.Asset, + Amount: tx.Amount, + From: tx.From, + To: tx.To, + Nonce: tx.Nonce, + Timestamp: tx.Timestamp.Format(time.RFC3339Nano), + }, + }) + } +}