diff --git a/cmd/wallet/main.go b/cmd/wallet/main.go index b215e0b..6362ae5 100644 --- a/cmd/wallet/main.go +++ b/cmd/wallet/main.go @@ -259,6 +259,7 @@ func run(ctx context.Context, args []string) error { logger.Printf("settler: endpoint=%s anchor=%v", settlerEP, anchor != nil) } + walletipc.Version = Version dispatcher := walletipc.NewDispatcher(w) // If a stale socket exists from a previous crash, drop it. unix sockets diff --git a/manifest.json b/manifest.json index 5821d71..9837ecc 100644 --- a/manifest.json +++ b/manifest.json @@ -28,7 +28,8 @@ "wallet.settler.history", "wallet.settler.transfer", "wallet.hookPreSendMessage", - "wallet.hookPostRecvMessage" + "wallet.hookPostRecvMessage", + "wallet.help" ], "grants": [ {"cap": "fs.write", "target": "$APP/data.db"}, diff --git a/pkg/walletipc/api.go b/pkg/walletipc/api.go index 79875ca..ab4c517 100644 --- a/pkg/walletipc/api.go +++ b/pkg/walletipc/api.go @@ -48,6 +48,7 @@ const ( var AllMethods = []string{ MethodBalance, MethodBalances, MethodAddress, MethodRequest, MethodPay, MethodVerify, MethodSettle, MethodTopup, MethodHistory, MethodSpendCaps, + MethodHelp, } // AllEVMMethods is the extra set the dispatcher registers when the diff --git a/pkg/walletipc/dispatcher.go b/pkg/walletipc/dispatcher.go index 388f51f..d58bd2b 100644 --- a/pkg/walletipc/dispatcher.go +++ b/pkg/walletipc/dispatcher.go @@ -28,6 +28,7 @@ func NewDispatcher(w *wallet.Wallet) *ipc.Dispatcher { d.Register(MethodSettle, settleHandler(w)) d.Register(MethodTopup, topupHandler(w)) d.Register(MethodHistory, historyHandler(w)) + d.Register(MethodHelp, helpHandler(w)) RegisterEVM(d, w) RegisterSettler(d, w) return d diff --git a/pkg/walletipc/help.go b/pkg/walletipc/help.go new file mode 100644 index 0000000..8b8cff3 --- /dev/null +++ b/pkg/walletipc/help.go @@ -0,0 +1,76 @@ +package walletipc + +import ( + "context" + "encoding/json" + + "github.com/pilot-protocol/app-store/pkg/ipc" + "github.com/pilot-protocol/wallet/pkg/wallet" +) + +// MethodHelp is the app-store discovery contract: every app answers +// .help with its methods and their params, so an agent can call the +// wallet correctly without reading the source. +const MethodHelp = "wallet.help" + +// Version is the app version wallet.help reports. cmd/wallet sets it from +// its own Version, which a test pins to manifest.json's app_version. +var Version = "dev" + +type helpMethod struct { + Method string `json:"method"` + Summary string `json:"summary"` + Params map[string]string `json:"params,omitempty"` + Duration string `json:"duration"` +} + +var helpCore = []helpMethod{ + {MethodAddress, "This wallet's Pilot payment address.", nil, "fast"}, + {MethodBalance, "Balance of one asset.", map[string]string{"asset": "string (required) — asset code, e.g. \"PILOT\""}, "fast"}, + {MethodBalances, "Balances of every asset this wallet holds.", nil, "fast"}, + {MethodSpendCaps, "Configured spend caps with their live rolling-window usage.", nil, "fast"}, + {MethodHistory, "Transaction history, newest first.", map[string]string{ + "limit": "int — page size", "before_unix_nano": "int — cursor: the oldest timestamp from the prior page", + "since_unix_nano": "int — only newer transactions", "asset": "string — filter by asset", "kind": "string — filter by transaction kind"}, "fast"}, + {MethodRequest, "Create a payment challenge for a peer to pay.", map[string]string{ + "amount": "int (required) — amount in the asset's smallest unit", "asset": "string (required)", + "expires_in_seconds": "int (required) — challenge lifetime", "memo": "string — shown to the payer"}, "fast"}, + {MethodPay, "Sign a payment authorization for a challenge. Enforced against spend caps.", map[string]string{"challenge": "object (required) — from the payee's wallet.request"}, "fast"}, + {MethodVerify, "Check a signed authorization against its challenge without settling.", map[string]string{"challenge": "object (required)", "signed_auth": "object (required) — from wallet.pay"}, "fast"}, + {MethodSettle, "Verify and settle a signed authorization into this wallet.", map[string]string{"challenge": "object (required)", "signed_auth": "object (required)"}, "fast"}, + {MethodTopup, "Credit this wallet from an external source.", map[string]string{"asset": "string (required)", "amount": "int (required) — amount in the asset's smallest unit", "source": "string (required) — where the funds came from"}, "fast"}, +} + +var helpEVM = []helpMethod{ + {MethodEVMChains, "EVM chains this wallet is configured for (USDC on Base, Ethereum, Polygon by default).", nil, "fast"}, + {MethodEVMAddress, "This wallet's EVM address on a chain.", map[string]string{"chain_id": "int — omit for the primary chain"}, "fast"}, + {MethodEVMBalance, "USDC balance on a chain.", map[string]string{"chain_id": "int — omit for the primary chain"}, "med"}, + {MethodEVMSatisfy, "Pay an x402 payment contract with an EIP-3009 authorization. Enforced against spend caps.", map[string]string{"contract": "object (required) — the x402 payment contract", "chain_id": "int"}, "med"}, + {MethodEVMVerify, "Verify an x402 receipt against its contract.", map[string]string{"contract": "object (required)", "receipt": "object (required)", "chain_id": "int"}, "med"}, +} + +var helpSettler = []helpMethod{ + {MethodSettlerIdentity, "The settler this wallet is wired to, and its identity.", nil, "fast"}, + {MethodSettlerBalance, "This wallet's balance held at the settler.", map[string]string{"asset": "string — asset symbol"}, "fast"}, + {MethodSettlerHistory, "This wallet's settler transaction history.", map[string]string{"limit": "int — page size"}, "fast"}, + {MethodSettlerTransfer, "Transfer through the settler. Enforced against spend caps.", map[string]string{"to": "string (required) — recipient's hex ed25519 pubkey", "asset": "string (required), e.g. \"USDC\"", "amount": "int (required) — smallest unit", "memo": "string", "expires_in_seconds": "int — default 300"}, "med"}, +} + +func helpHandler(w *wallet.Wallet) ipc.Handler { + return func(_ context.Context, _ *ipc.Envelope) (json.RawMessage, error) { + methods := append([]helpMethod{}, helpCore...) + if w.HasEVM() { + methods = append(methods, helpEVM...) + } + if w.HasSettler() { + methods = append(methods, helpSettler...) + } + methods = append(methods, helpMethod{MethodHelp, "This list.", nil, "fast"}) + return encode(map[string]any{ + "app": "io.pilot.wallet", + "version": Version, + "description": "Pilot's reference wallet: pay and get paid between agents (Pilot challenge/authorization flow) and pay x402 USDC contracts on EVM chains, all under per-asset spend caps.", + "methods": methods, + }) + } +} diff --git a/pkg/walletipc/help_test.go b/pkg/walletipc/help_test.go new file mode 100644 index 0000000..eaa7ca8 --- /dev/null +++ b/pkg/walletipc/help_test.go @@ -0,0 +1,39 @@ +package walletipc + +import ( + "testing" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// wallet.help is the discovery contract: it must describe every method the +// dispatcher actually serves, no more and no less. +func TestHelpListsEveryRegisteredMethod(t *testing.T) { + conn, w := servedWallet(t, addrAlice) + var resp struct { + App string `json:"app"` + Version string `json:"version"` + Methods []struct { + Method string `json:"method"` + } `json:"methods"` + } + if err := ipc.Call(conn, MethodHelp, nil, &resp); err != nil { + t.Fatal(err) + } + if resp.App != "io.pilot.wallet" || resp.Version != Version || len(resp.Methods) == 0 { + t.Errorf("app/version = %q/%q", resp.App, resp.Version) + } + listed := map[string]bool{} + for _, m := range resp.Methods { + listed[m.Method] = true + } + for _, m := range NewDispatcher(w).Methods() { + if !listed[m] { + t.Errorf("registered method %q missing from wallet.help", m) + } + delete(listed, m) + } + for m := range listed { + t.Errorf("wallet.help lists %q, which is not registered", m) + } +}