From 573f8c0ce82eaef792ca6cdc5171767068892502 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 7 Sep 2026 16:47:18 +0800 Subject: [PATCH] feat(x402): add the signer groundwork for an x402 command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x402-cli signs its payments through @bankofai/agent-wallet. Migrating its commands into wallet-cli means re-pointing that signer at a wallet-cli account; this is the seam that makes the rest of that migration mechanical. No x402 SDK is a dependency yet, and no command is wired. - domain/x402/network-id — convert between wallet-cli network ids and the CAIP-2 ids x402 uses. TRON differs only in base (tron:728126428 is tron:0x2b6653dc); eip155 identifiers are identical on both sides. - application/contracts/x402-payer — PayerSigner and PayerPolicy. The contract carries two closures rather than the domain Signer, because an x402 scheme calls the wallet from deep inside a payment flow, where nothing can run a Ledger's precheck / prompt / abort ceremony. - application/services/x402/payer-signer — resolve the active account, refusing a watch-only one before any keystore decrypt, and apply that ceremony once per signature. The TRON allowance path signs an approve transaction before the payment, so each signature needs its own round. - adapters/outbound/x402/signer-bridge — reshape the payer for the x402 SDK, declared structurally so no x402 package is imported. It is the one place every typed-data payload passes through, so it carries all three guards: the payload's payer must be the selected account, a GasFree maxFee must stay within the caller's ceiling, and the returned signature must be for the struct that was requested. The first two refuse before any signature is requested, so a rejected payment never reaches a device prompt. - domain/typed-data — resolvePrimaryType, so those guards work from the payload's real signing root instead of an optional field. Without it, a payload that omitted primaryType skipped all three checks. - three error codes: payer_mismatch, fee_cap_exceeded and signed_payload_mismatch, with their rows in machine-interface.md. --- ts/docs/machine-interface.md | 3 + .../outbound/x402/signer-bridge.test.ts | 223 ++++++++++++++++++ .../adapters/outbound/x402/signer-bridge.ts | 153 ++++++++++++ ts/src/application/contracts/index.ts | 1 + ts/src/application/contracts/x402-payer.ts | 25 ++ .../services/x402/payer-signer.test.ts | 119 ++++++++++ .../application/services/x402/payer-signer.ts | 28 +++ ts/src/domain/errors/codes.ts | 3 + ts/src/domain/typed-data/index.test.ts | 36 ++- ts/src/domain/typed-data/index.ts | 24 ++ ts/src/domain/x402/network-id.test.ts | 43 ++++ ts/src/domain/x402/network-id.ts | 41 ++++ 12 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 ts/src/adapters/outbound/x402/signer-bridge.test.ts create mode 100644 ts/src/adapters/outbound/x402/signer-bridge.ts create mode 100644 ts/src/application/contracts/x402-payer.ts create mode 100644 ts/src/application/services/x402/payer-signer.test.ts create mode 100644 ts/src/application/services/x402/payer-signer.ts create mode 100644 ts/src/domain/x402/network-id.test.ts create mode 100644 ts/src/domain/x402/network-id.ts diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index 25e220535..c32bcc6d7 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -244,6 +244,9 @@ Common codes at exit **1** (execution — runtime failure): | `auth_failed` | Wrong master password (decryption failed) | | `signing_rejected` / `transaction_rejected` | Signing or broadcast rejected (device or chain) | | `watch_only_no_signer` | The account is watch-only and cannot sign | +| `payer_mismatch` | An x402 payment payload names a payer other than the selected account. The payment is refused before any signature is requested | +| `fee_cap_exceeded` | An x402 GasFree authorization's `maxFee` exceeds the ceiling the caller set | +| `signed_payload_mismatch` | The signature returned is for a different struct than the one that was requested | | `invalid_mnemonic` / `invalid_private_key` | Storage validation rejected a malformed mnemonic or private key; interactive import normally catches it at the prompt and asks again | | `token_metadata_unavailable` | Required token metadata could not be read from the selected network. This one crosses exit codes: most sites raise it at exit `1`, but `tx send` on TRON raises it at exit **2** when a contract answers no `decimals()` and the address book has no entry either — there, the call itself has to change | | `wrong_device_seed` | Connected Ledger does not match the registered account | diff --git a/ts/src/adapters/outbound/x402/signer-bridge.test.ts b/ts/src/adapters/outbound/x402/signer-bridge.test.ts new file mode 100644 index 000000000..4ae503c41 --- /dev/null +++ b/ts/src/adapters/outbound/x402/signer-bridge.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi } from "vitest"; +import { toX402Wallet } from "./signer-bridge.js"; +import type { PayerSigner } from "../../../application/contracts/x402-payer.js"; +import type { TypedDataPayload } from "../../../domain/types/index.js"; + +const EVM_ADDRESS = "0xaB5801a7D398351b8bE11C439e05C5B3259aeC9B"; +const TRON_ADDRESS = "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ"; +// The same TRON address in the 41-prefixed hex form a counterparty may send instead. +const TRON_HEX = "4119e7e376e7c213b7e7e7e46cc70a5dd086daff2a"; + +const payerOf = (address: string, signature = "sig", primaryType = "Transfer"): PayerSigner => ({ + address, + signTypedData: vi.fn(async () => ({ signature, digest: "0xdig", primaryType })), + signTransaction: vi.fn(async (tx: unknown) => tx), +}); + +const evmPayload = (from: string): TypedDataPayload => ({ + domain: { name: "x402" }, + types: { Transfer: [{ name: "from", type: "address" }] }, + primaryType: "Transfer", + message: { from }, +}); + +const permitPayload = (user: string, maxFee: string): TypedDataPayload => ({ + domain: { name: "GasFreeController" }, + types: { + PermitTransfer: [ + { name: "user", type: "address" }, + { name: "maxFee", type: "uint256" }, + ], + }, + primaryType: "PermitTransfer", + message: { user, maxFee }, +}); + +// Same structs as permitPayload/evmPayload but with `primaryType` OMITTED — the shape a well-formed +// single-root payload is allowed to arrive in (see domain/typed-data). The bridge must still resolve +// the root and run every guard against it, not skip the guards because the field is absent. +const permitPayloadNoPrimaryType = (user: string, maxFee: string): TypedDataPayload => ({ + domain: { name: "GasFreeController" }, + types: { + PermitTransfer: [ + { name: "user", type: "address" }, + { name: "maxFee", type: "uint256" }, + ], + }, + message: { user, maxFee }, +}); + +const evmPayloadNoPrimaryType = (from: string): TypedDataPayload => ({ + domain: { name: "x402" }, + types: { Transfer: [{ name: "from", type: "address" }] }, + message: { from }, +}); + +describe("toX402Wallet", () => { + it("reports the payer's address", () => { + expect(toX402Wallet(payerOf(EVM_ADDRESS), { family: "evm" }).getAddress()).toBe(EVM_ADDRESS); + }); + + // Finding 2: TRON's scheme (createClientTronSigner) calls getAddress(); EVM's (toClientEvmSigner) + // reads a viem-account-shaped `address` property. Both spellings must carry the same value. + it("exposes the payer's address under both spellings the two schemes read, for evm", () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS), { family: "evm" }); + expect(wallet.address).toBe(wallet.getAddress()); + expect(wallet.address).toBe(EVM_ADDRESS); + }); + + it("exposes the payer's address under both spellings the two schemes read, for tron", () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS), { family: "tron" }); + expect(wallet.address).toBe(wallet.getAddress()); + expect(wallet.address).toBe(TRON_ADDRESS); + }); + + it("returns a 0x-prefixed signature even when the signer omits the prefix", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS, "abcd"), { family: "evm" }); + expect(await wallet.signTypedData(evmPayload(EVM_ADDRESS))).toBe("0xabcd"); + }); + + it("keeps a signature that is already prefixed", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS, "0xabcd"), { family: "evm" }); + expect(await wallet.signTypedData(evmPayload(EVM_ADDRESS))).toBe("0xabcd"); + }); + + it("accepts an EVM payer that differs only in case", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS), { family: "evm" }); + await expect( + wallet.signTypedData(evmPayload(EVM_ADDRESS.toLowerCase())), + ).resolves.toBeDefined(); + }); + + it("refuses to sign for a different EVM payer", async () => { + const payer = payerOf(EVM_ADDRESS); + const wallet = toX402Wallet(payer, { family: "evm" }); + await expect( + wallet.signTypedData(evmPayload("0x2222222222222222222222222222222222222222")), + ).rejects.toMatchObject({ code: "payer_mismatch" }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("accepts a TRON payer given in hex form", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { family: "tron" }); + await expect(wallet.signTypedData(permitPayload(TRON_HEX, "100"))).resolves.toBeDefined(); + }); + + it("refuses to sign for a different TRON payer", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { family: "tron" }); + await expect( + wallet.signTypedData(permitPayload("TBvJUBXorwBPzqvV38vjDgegj5Eh6g2Tsq", "100")), + ).rejects.toMatchObject({ code: "payer_mismatch" }); + }); + + it("signs a PermitTransfer whose fee is within the ceiling", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { + family: "tron", + maxGasfreeFeeRaw: "100", + }); + await expect(wallet.signTypedData(permitPayload(TRON_ADDRESS, "100"))).resolves.toBeDefined(); + }); + + it("refuses a PermitTransfer whose fee exceeds the ceiling", async () => { + const payer = payerOf(TRON_ADDRESS, "sig", "PermitTransfer"); + const wallet = toX402Wallet(payer, { family: "tron", maxGasfreeFeeRaw: "100" }); + await expect(wallet.signTypedData(permitPayload(TRON_ADDRESS, "101"))).rejects.toMatchObject({ + code: "fee_cap_exceeded", + }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("refuses a PermitTransfer whose fee is not a whole number", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { + family: "tron", + maxGasfreeFeeRaw: "100", + }); + await expect(wallet.signTypedData(permitPayload(TRON_ADDRESS, "ten"))).rejects.toMatchObject({ + code: "fee_cap_exceeded", + }); + }); + + // Finding 4: BigInt(maxGasfreeFeeRaw) used to sit outside the try block, so an unparseable + // ceiling threw a bare, uncoded SyntaxError instead of a ChainError. + it("refuses a PermitTransfer when the policy's own fee ceiling will not parse", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { + family: "tron", + maxGasfreeFeeRaw: "1e6", + }); + await expect(wallet.signTypedData(permitPayload(TRON_ADDRESS, "100"))).rejects.toMatchObject({ + code: "fee_cap_exceeded", + }); + }); + + it("ignores the fee ceiling for a struct that is not a PermitTransfer", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS), { family: "evm", maxGasfreeFeeRaw: "0" }); + await expect(wallet.signTypedData(evmPayload(EVM_ADDRESS))).resolves.toBeDefined(); + }); + + it("refuses a signature produced for a different struct", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS, "sig", "SomethingElse"), { family: "evm" }); + await expect(wallet.signTypedData(evmPayload(EVM_ADDRESS))).rejects.toMatchObject({ + code: "signed_payload_mismatch", + }); + }); + + // Finding 1: an absent `primaryType` must not disable the guards. `declaredPayer`, + // `assertFeeWithinCap` and `assertSignedTheRequest` all branched on `payload.primaryType` + // directly, so a payload that simply omitted the field slipped past every one of them. + it("still rejects a payer mismatch and an over-cap fee when primaryType is omitted", async () => { + const payer = payerOf(TRON_ADDRESS, "sig", "PermitTransfer"); + const wallet = toX402Wallet(payer, { family: "tron", maxGasfreeFeeRaw: "100" }); + await expect( + wallet.signTypedData( + permitPayloadNoPrimaryType("TBvJUBXorwBPzqvV38vjDgegj5Eh6g2Tsq", "999999999"), + ), + ).rejects.toMatchObject({ code: "payer_mismatch" }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("resolves the root and signs a PermitTransfer with omitted primaryType when payer and fee are fine", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { + family: "tron", + maxGasfreeFeeRaw: "100", + }); + await expect( + wallet.signTypedData(permitPayloadNoPrimaryType(TRON_ADDRESS, "100")), + ).resolves.toBeDefined(); + }); + + it("rejects an EVM payer mismatch when primaryType is omitted", async () => { + const payer = payerOf(EVM_ADDRESS); + const wallet = toX402Wallet(payer, { family: "evm" }); + await expect( + wallet.signTypedData(evmPayloadNoPrimaryType("0x2222222222222222222222222222222222222222")), + ).rejects.toMatchObject({ code: "payer_mismatch" }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("passes a TRON transaction through untouched", async () => { + const payer = payerOf(TRON_ADDRESS); + const tx = { raw_data: {}, txID: "abc" }; + expect(await toX402Wallet(payer, { family: "tron" }).signTransaction(tx)).toEqual(tx); + expect(payer.signTransaction).toHaveBeenCalledWith(tx); + }); + + it("unwraps an EVM signature to the raw serialisation x402 broadcasts", async () => { + const payer: PayerSigner = { + address: EVM_ADDRESS, + signTypedData: vi.fn(), + signTransaction: vi.fn(async () => ({ raw: "0xraw", hash: "0xhash" })), + }; + expect(await toX402Wallet(payer, { family: "evm" }).signTransaction({})).toBe("0xraw"); + }); + + it("refuses an EVM signature that carries no raw transaction", async () => { + const payer: PayerSigner = { + address: EVM_ADDRESS, + signTypedData: vi.fn(), + signTransaction: vi.fn(async () => ({ hash: "0xhash" })), + }; + await expect(toX402Wallet(payer, { family: "evm" }).signTransaction({})).rejects.toMatchObject({ + code: "signed_payload_mismatch", + }); + }); +}); diff --git a/ts/src/adapters/outbound/x402/signer-bridge.ts b/ts/src/adapters/outbound/x402/signer-bridge.ts new file mode 100644 index 000000000..0d3a4cfe0 --- /dev/null +++ b/ts/src/adapters/outbound/x402/signer-bridge.ts @@ -0,0 +1,153 @@ +/** + * signer-bridge — a wallet-cli PayerSigner in the shape the x402 schemes call. + * + * The shape is described STRUCTURALLY rather than imported: this file must compile before any + * x402 package is a dependency, and the SDK only ever duck-types the wallet it is handed. The two + * schemes duck-type it differently: TRON's (`createClientTronSigner`) calls `getAddress()`, EVM's + * (`toClientEvmSigner`) reads a viem-account-shaped `address` property. `X402Wallet` carries both + * spellings of the same value so this bridge need not be reopened once a scheme is actually wired. + * + * This is also the only place every typed-data payload passes through, which is why all three + * guards live here rather than at the call sites. Two of them refuse BEFORE the signature is + * requested, so a rejected payment never reaches a device prompt. + */ +import type { PayerPolicy, PayerSigner } from "../../../application/contracts/x402-payer.js"; +import type { TypedDataPayload, TypedDataSignature } from "../../../domain/types/index.js"; +import type { ChainFamily } from "../../../domain/family/chain-family.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { tronHexToBase58 } from "../../../domain/address/index.js"; +import { resolvePrimaryType } from "../../../domain/typed-data/index.js"; + +/** + * The wallet an x402 scheme calls. Structural on purpose — see the module comment. TRON's scheme + * reads `getAddress()`; EVM's reads `address`. Both are the same payer address. + */ +export interface X402Wallet { + readonly address: string; + getAddress(): string; + signTypedData(payload: TypedDataPayload): Promise; + signTransaction(tx: unknown): Promise; +} + +/** TIP-712 GasFree authorization; the only struct whose fee this bridge caps. */ +const PERMIT_TRANSFER = "PermitTransfer"; + +/** + * Which field names the payer. + * + * `from` is the payer in the EVM exact/permit2 structs; the GasFree `PermitTransfer` calls the + * same party `user`. A struct that names neither (a nonce read, say) has no payer to check. + */ +function declaredPayer(payload: TypedDataPayload, primaryType: string): unknown { + return primaryType === PERMIT_TRANSFER ? payload.message.user : payload.message.from; +} + +/** TRON addresses travel as base58 or as 41-prefixed hex; EVM addresses are case-insensitive. */ +function samePayer(family: ChainFamily, a: string, b: string): boolean { + return family === "tron" + ? tronHexToBase58(a) === tronHexToBase58(b) + : a.toLowerCase() === b.toLowerCase(); +} + +function assertPayerMatches( + payload: TypedDataPayload, + primaryType: string, + address: string, + family: ChainFamily, +): void { + const declared = declaredPayer(payload, primaryType); + if (declared === undefined) return; + if (typeof declared !== "string" || !samePayer(family, declared, address)) { + throw new ChainError( + "payer_mismatch", + `this payment names a different payer than the selected account ${address}`, + { account: address, payload: String(declared) }, + ); + } +} + +/** + * A GasFree authorization signs a maxFee the service is then entitled to take, so a caller that + * set a ceiling must have it enforced against the FINAL payload, after the SDK has filled the + * value in. Both the payload's fee and the policy's own ceiling are parsed inside this guarded + * path: a ceiling that will not parse must never be treated as "no ceiling". + */ +function assertFeeWithinCap( + payload: TypedDataPayload, + primaryType: string, + maxGasfreeFeeRaw?: string, +): void { + if (maxGasfreeFeeRaw === undefined || primaryType !== PERMIT_TRANSFER) return; + const declared = payload.message.maxFee; + let fee: bigint; + let cap: bigint; + try { + fee = BigInt(declared as string | number | bigint); + cap = BigInt(maxGasfreeFeeRaw); + } catch { + throw new ChainError( + "fee_cap_exceeded", + `GasFree maxFee ${String(declared)} or cap ${maxGasfreeFeeRaw} is not a whole number`, + ); + } + if (fee < 0n || fee > cap) { + throw new ChainError( + "fee_cap_exceeded", + `GasFree maxFee ${fee} exceeds the ${maxGasfreeFeeRaw} ceiling`, + { fee: fee.toString(), cap: maxGasfreeFeeRaw }, + ); + } +} + +/** A signature is only evidence about the struct it was produced for. */ +function assertSignedTheRequest(signed: TypedDataSignature, primaryType: string): void { + if (signed.primaryType !== primaryType) { + throw new ChainError( + "signed_payload_mismatch", + `signed ${signed.primaryType} but ${primaryType} was requested`, + ); + } +} + +const prefixedHex = (value: string): string => (value.startsWith("0x") ? value : `0x${value}`); + +/** + * `evmSignStrategy.sign` returns `{ raw, hash }` — the serialisation plus the locally derived id. + * x402 wants only the serialisation it will broadcast. TRON's strategy returns the signed + * transaction object the SDK already expects, so it passes through as it is. + */ +function evmRawTransaction(signed: unknown): string { + const raw = (signed as { raw?: unknown } | null)?.raw; + if (typeof raw !== "string") { + throw new ChainError("signed_payload_mismatch", "the EVM signature carried no raw transaction"); + } + return raw; +} + +export function toX402Wallet(payer: PayerSigner, policy: PayerPolicy): X402Wallet { + return { + address: payer.address, + getAddress: () => payer.address, + async signTypedData(payload) { + // Resolve the effective root ONCE and feed every guard from it, rather than branching each + // guard on `payload.primaryType` directly — a payload that legitimately omits the field (see + // domain/typed-data) must still be checked, not silently waved through. + const primaryType = resolvePrimaryType(payload); + if (primaryType === undefined) { + throw new ChainError( + "signed_payload_mismatch", + "typed data has no primaryType and its root type cannot be resolved unambiguously", + ); + } + assertPayerMatches(payload, primaryType, payer.address, policy.family); + assertFeeWithinCap(payload, primaryType, policy.maxGasfreeFeeRaw); + const signed = await payer.signTypedData(payload); + assertSignedTheRequest(signed, primaryType); + return prefixedHex(signed.signature); + }, + async signTransaction(tx) { + const signed = await payer.signTransaction(tx); + return policy.family === "evm" ? evmRawTransaction(signed) : signed; + }, + }; +} diff --git a/ts/src/application/contracts/index.ts b/ts/src/application/contracts/index.ts index 2dce1c944..a7300b762 100644 --- a/ts/src/application/contracts/index.ts +++ b/ts/src/application/contracts/index.ts @@ -1,3 +1,4 @@ export * from "./execution-policy.js"; export * from "./execution-scope.js"; export * from "./progress.js"; +export * from "./x402-payer.js"; diff --git a/ts/src/application/contracts/x402-payer.ts b/ts/src/application/contracts/x402-payer.ts new file mode 100644 index 000000000..596ae7155 --- /dev/null +++ b/ts/src/application/contracts/x402-payer.ts @@ -0,0 +1,25 @@ +/** + * PayerSigner — the signing capability an x402 payment flow consumes. + * + * Deliberately NOT the domain `Signer`. An x402 scheme calls the wallet from deep inside a + * payment flow, where nothing can run a device's precheck / prompt / abort ceremony. So the + * ceremony is applied at construction (`createPayerSigner`) and what crosses this port is two + * closures that have already been through it. The outbound adapter therefore never learns that + * Ledger accounts exist. + */ +import type { ChainFamily } from "../../domain/family/chain-family.js"; +import type { TypedDataPayload, TypedDataSignature } from "../../domain/types/index.js"; + +export interface PayerSigner { + /** family-native spelling: base58 `T...` for tron, `0x...` for evm. */ + readonly address: string; + signTypedData(payload: TypedDataPayload): Promise; + signTransaction(tx: unknown): Promise; +} + +/** Per-payment limits the bridge enforces on every payload it passes on. */ +export interface PayerPolicy { + readonly family: ChainFamily; + /** GasFree `PermitTransfer.maxFee` ceiling in base units; absent means no ceiling. */ + readonly maxGasfreeFeeRaw?: string; +} diff --git a/ts/src/application/services/x402/payer-signer.test.ts b/ts/src/application/services/x402/payer-signer.test.ts new file mode 100644 index 000000000..8206d61cc --- /dev/null +++ b/ts/src/application/services/x402/payer-signer.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi } from "vitest"; +import { createPayerSigner } from "./payer-signer.js"; +import type { SignerResolver } from "../signer/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { Signer, TypedDataPayload } from "../../../domain/types/index.js"; +import { WalletError } from "../../../domain/errors/index.js"; + +const PAYLOAD: TypedDataPayload = { + domain: { name: "x402" }, + types: { Transfer: [{ name: "from", type: "address" }] }, + primaryType: "Transfer", + message: { from: "0xabc" }, +}; + +const scope = (): TransactionScope & { emitted: unknown[] } => ({ + activeAccount: "wlt_k", + timeoutMs: 50, + wait: false, + waitTimeoutMs: 50, + emitted: [] as unknown[], + emit(e: unknown) { + (this.emitted as unknown[]).push(e); + }, + warn() {}, + resolveAddress: () => "0xdead", +}); + +const resolverOf = (signer: Signer, assertCanSign = vi.fn()) => { + const resolve = vi.fn(() => signer); + return { assertCanSign, resolve } as unknown as SignerResolver; +}; + +describe("createPayerSigner", () => { + it("refuses a watch-only account before resolving a signer", () => { + const assertCanSign = vi.fn(() => { + throw new WalletError("watch_only_no_signer", "watch-only account cannot sign"); + }); + const resolve = vi.fn(); + const signers = { assertCanSign, resolve } as unknown as SignerResolver; + expect(() => createPayerSigner(signers, scope(), "evm")).toThrow( + expect.objectContaining({ code: "watch_only_no_signer" }), + ); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("exposes the resolved signer's address", () => { + const signer = { kind: "software", address: "0xdead" } as unknown as Signer; + expect(createPayerSigner(resolverOf(signer), scope(), "evm").address).toBe("0xdead"); + }); + + // Finding 8: `resolverOf` used to ignore its arguments entirely, so all six tests stayed green + // even if `payer-signer.ts` transposed the two arguments to `assertCanSign`/`resolve`. + it("calls assertCanSign and resolve with the active account and family, in that order", () => { + const signer = { kind: "software", address: "0xdead" } as unknown as Signer; + const assertCanSign = vi.fn(); + const signers = resolverOf(signer, assertCanSign); + createPayerSigner(signers, scope(), "evm"); + expect(assertCanSign).toHaveBeenCalledWith("wlt_k", "evm"); + expect(signers.resolve).toHaveBeenCalledWith("wlt_k", "evm"); + const resolveOrder = (signers.resolve as ReturnType).mock.invocationCallOrder[0]; + expect(assertCanSign.mock.invocationCallOrder[0]).toBeLessThan(resolveOrder as number); + }); + + it("passes a software signature straight through with no device event", async () => { + const signTypedData = vi.fn(async () => ({ + signature: "0xsig", + digest: "0xdig", + primaryType: "Transfer", + })); + const signer = { kind: "software", address: "0xdead", signTypedData } as unknown as Signer; + const s = scope(); + const out = await createPayerSigner(resolverOf(signer), s, "evm").signTypedData(PAYLOAD); + expect(out.signature).toBe("0xsig"); + expect(signTypedData).toHaveBeenCalledWith(PAYLOAD, {}); + expect(s.emitted).toEqual([]); + }); + + it("runs the device ceremony for a device signer", async () => { + const precheck = vi.fn(async () => {}); + const signer = { + kind: "device", + address: "0xdead", + precheck, + signTypedData: async () => ({ signature: "0xsig", digest: "0xdig", primaryType: "Transfer" }), + } as unknown as Signer; + const s = scope(); + await createPayerSigner(resolverOf(signer), s, "evm").signTypedData(PAYLOAD); + expect(precheck).toHaveBeenCalledOnce(); + expect(s.emitted).toEqual([{ type: "awaiting_device", reason: "sign" }]); + }); + + // The TRON allowanceMode "auto" path may sign an approve transaction before the payment + // itself; each signature must get its own precheck and its own prompt. + it("runs one ceremony per signature", async () => { + const precheck = vi.fn(async () => {}); + const signer = { + kind: "device", + address: "0xdead", + precheck, + sign: async () => ({ raw: "0xraw", hash: "0xhash" }), + signTypedData: async () => ({ signature: "0xsig", digest: "0xdig", primaryType: "Transfer" }), + } as unknown as Signer; + const s = scope(); + const payer = createPayerSigner(resolverOf(signer), s, "evm"); + await payer.signTransaction({ to: "0xdead" }); + await payer.signTypedData(PAYLOAD); + expect(precheck).toHaveBeenCalledTimes(2); + expect(s.emitted).toHaveLength(2); + }); + + it("returns what the signer returned for a transaction", async () => { + const sign = vi.fn(async () => ({ raw: "0xraw", hash: "0xhash" })); + const signer = { kind: "software", address: "0xdead", sign } as unknown as Signer; + const tx = { to: "0xdead" }; + const out = await createPayerSigner(resolverOf(signer), scope(), "evm").signTransaction(tx); + expect(sign).toHaveBeenCalledWith(tx, {}); + expect(out).toEqual({ raw: "0xraw", hash: "0xhash" }); + }); +}); diff --git a/ts/src/application/services/x402/payer-signer.ts b/ts/src/application/services/x402/payer-signer.ts new file mode 100644 index 000000000..00fb1f2ce --- /dev/null +++ b/ts/src/application/services/x402/payer-signer.ts @@ -0,0 +1,28 @@ +/** + * createPayerSigner — the active account, as an x402 payer. + * + * `assertCanSign` runs FIRST so a watch-only account fails before any keystore decrypt or network + * call, the same ordering every write command uses. The keystore itself is still untouched at this + * point: `SoftwareSigner` decrypts lazily on its first signature, so a dry-run path that builds a + * payer and never signs never prompts for the master password. + */ +import type { ChainFamily } from "../../../domain/family/chain-family.js"; +import type { PayerSigner } from "../../contracts/x402-payer.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { SignerResolver } from "../signer/index.js"; +import { obtainSignature } from "../signing/obtain-signature.js"; + +export function createPayerSigner( + signers: SignerResolver, + scope: TransactionScope, + family: ChainFamily, +): PayerSigner { + signers.assertCanSign(scope.activeAccount, family); + const signer = signers.resolve(scope.activeAccount, family); + return { + address: signer.address, + signTypedData: (payload) => + obtainSignature(signer, scope, (opts) => signer.signTypedData(payload, opts)), + signTransaction: (tx) => obtainSignature(signer, scope, (opts) => signer.sign(tx, opts)), + }; +} diff --git a/ts/src/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts index 7fd474dea..462768f8e 100644 --- a/ts/src/domain/errors/codes.ts +++ b/ts/src/domain/errors/codes.ts @@ -95,6 +95,9 @@ export const ERROR_CODES = { tx_integrity: { exit: 1, retry: "never", meaning: "the transaction re-encoded differently than it arrived — it was altered in flight" }, chain_id_mismatch: { exit: 1, retry: "never", meaning: "the transaction was built for a different chain than the one selected" }, signing_rejected: { exit: 1, retry: "never", meaning: "the signature was declined on the device" }, + payer_mismatch: { exit: 1, retry: "never", meaning: "the payload names a payer other than the signing account" }, + fee_cap_exceeded: { exit: 1, retry: "never", meaning: "the payload's fee exceeds the ceiling the caller set" }, + signed_payload_mismatch: { exit: 1, retry: "never", meaning: "the signature is not for the struct that was requested" }, dry_run_violation: { exit: 1, retry: "never", meaning: "a --dry-run path attempted to broadcast; the attempt was barred" }, invalid_permission: { exit: 2, retry: "never", meaning: "no such permission group on the account, or it cannot be used here" }, not_authorized: { exit: 1, retry: "never", meaning: "the account is not permitted to perform this operation" }, diff --git a/ts/src/domain/typed-data/index.test.ts b/ts/src/domain/typed-data/index.test.ts index c2550d687..d6cecab68 100644 --- a/ts/src/domain/typed-data/index.test.ts +++ b/ts/src/domain/typed-data/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { normalizeTypedData } from "./index.js"; +import { normalizeTypedData, resolvePrimaryType } from "./index.js"; import { CliError } from "../errors/index.js"; const DOMAIN = { name: "SunPerp", version: "1", chainId: 728126428 }; @@ -260,3 +260,37 @@ describe("normalizeTypedData narrows types to the primaryType's closure", () => expect(Object.keys(p.types).sort()).toEqual(["Mail", "Person", "Unrelated"]); }); }); + +describe("resolvePrimaryType", () => { + const Person = [ + { name: "name", type: "string" }, + { name: "wallet", type: "address" }, + ]; + const Mail = [ + { name: "from", type: "Person" }, + { name: "to", type: "Person" }, + { name: "contents", type: "string" }, + ]; + + it("returns the declared primaryType without consulting types", () => { + expect(resolvePrimaryType({ types: { Mail, Person }, primaryType: "Mail" })).toBe("Mail"); + }); + + it("infers the single root when primaryType is omitted", () => { + expect(resolvePrimaryType({ types: { Mail, Person } })).toBe("Mail"); + }); + + it("returns undefined when more than one root exists and primaryType is omitted", () => { + expect( + resolvePrimaryType({ types: { Mail, Person, Receipt: [{ name: "id", type: "uint256" }] } }), + ).toBeUndefined(); + }); + + it("returns undefined when no root exists (a cycle with nothing outside it)", () => { + const types = { + A: [{ name: "b", type: "B" }], + B: [{ name: "a", type: "A" }], + }; + expect(resolvePrimaryType({ types })).toBeUndefined(); + }); +}); diff --git a/ts/src/domain/typed-data/index.ts b/ts/src/domain/typed-data/index.ts index 28906e3df..f54ce1ff9 100644 --- a/ts/src/domain/typed-data/index.ts +++ b/ts/src/domain/typed-data/index.ts @@ -65,6 +65,30 @@ function typeClosure( return Object.fromEntries(Object.entries(types).filter(([name]) => seen.has(name))); } +/** The struct names in `types` that no other struct references — the candidate signing roots. */ +function rootTypes(types: Record): string[] { + return Object.keys(types).filter((name) => !isReferencedType(types, name)); +} + +/** + * Resolve which struct a payload is signing: the declared `primaryType`, or — when the caller + * omitted it — the struct `types` reaches from nowhere else, PROVIDED there is exactly one such + * struct. Returns `undefined` when that root cannot be determined unambiguously (zero roots, e.g. + * a cycle with nothing outside it, or more than one candidate), the same condition under which an + * encoder handed the bare map would refuse to infer a root. + * + * Callers that must never silently treat "root unknown" as "no guard needed" — see + * `adapters/outbound/x402/signer-bridge.ts` — should refuse rather than proceed when this returns + * `undefined`. + */ +export function resolvePrimaryType( + payload: Pick, +): string | undefined { + if (payload.primaryType !== undefined) return payload.primaryType; + const roots = rootTypes(payload.types); + return roots.length === 1 ? roots[0] : undefined; +} + /** * Validate and canonicalize a caller-supplied typed-data payload. * - `EIP712Domain` is dropped from `types`: it describes `domain`, it is not a struct to hash, diff --git a/ts/src/domain/x402/network-id.test.ts b/ts/src/domain/x402/network-id.test.ts new file mode 100644 index 000000000..cc46298f2 --- /dev/null +++ b/ts/src/domain/x402/network-id.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { fromX402Network, toX402Network } from "./network-id.js"; + +// The full builtin set, as a literal table. A domain test may not import BUILTIN_NETWORKS +// (that lives in an outbound adapter), and a hand-written table is also the clearer contract: +// these exact pairs are what the migration relies on. +const PAIRS: Array<[{ family: "tron" | "evm"; chainId: string }, string]> = [ + [{ family: "tron", chainId: "728126428" }, "tron:0x2b6653dc"], + [{ family: "tron", chainId: "3448148188" }, "tron:0xcd8690dc"], + [{ family: "tron", chainId: "2494104990" }, "tron:0x94a9059e"], + [{ family: "evm", chainId: "1" }, "eip155:1"], + [{ family: "evm", chainId: "11155111" }, "eip155:11155111"], + [{ family: "evm", chainId: "56" }, "eip155:56"], + [{ family: "evm", chainId: "97" }, "eip155:97"], +]; + +describe("toX402Network", () => { + it.each(PAIRS)("renders %j as its x402 id", (network, id) => { + expect(toX402Network(network)).toBe(id); + }); +}); + +describe("fromX402Network", () => { + it.each(PAIRS)("parses the x402 id back into %j", (network, id) => { + expect(fromX402Network(id)).toEqual(network); + }); + + it("accepts an id whose case differs", () => { + expect(fromX402Network("TRON:0x2B6653DC")).toEqual({ family: "tron", chainId: "728126428" }); + }); + + it("rejects an unknown namespace", () => { + expect(() => fromX402Network("solana:mainnet")).toThrow( + expect.objectContaining({ code: "unsupported_network" }), + ); + }); + + it("rejects a malformed reference", () => { + expect(() => fromX402Network("eip155:mainnet")).toThrow( + expect.objectContaining({ code: "unsupported_network" }), + ); + }); +}); diff --git a/ts/src/domain/x402/network-id.ts b/ts/src/domain/x402/network-id.ts new file mode 100644 index 000000000..ae2b7ac6d --- /dev/null +++ b/ts/src/domain/x402/network-id.ts @@ -0,0 +1,41 @@ +/** + * x402 network identifiers. + * + * x402 addresses a chain by CAIP-2. For `eip155` that is the EIP-155 chain id in decimal — the + * same string `NetworkDescriptor.chainId` already carries. For TRON, CAIP-2 uses the chain id in + * HEXADECIMAL (`tron:0x2b6653dc`) while wallet-cli's canonical id uses decimal + * (`tron:728126428`); the two name the same number in different bases. + * + * Pure: no registry lookup, no I/O. Whether a parsed id corresponds to a network this wallet is + * configured for is the NetworkRegistry's question, not this module's. + */ +import type { ChainFamily } from "../family/chain-family.js"; +import { UsageError } from "../errors/index.js"; + +export interface X402NetworkIdentity { + family: ChainFamily; + /** decimal, matching NetworkDescriptor.chainId. */ + chainId: string; +} + +/** `tron:` or `eip155:`; a hex reference is also accepted for eip155 so a + * round-trip never depends on which base a counterparty chose. */ +const X402_ID = /^(tron|eip155):(0x[0-9a-f]+|[0-9]+)$/; + +export function toX402Network(network: X402NetworkIdentity): string { + const value = BigInt(network.chainId); + return network.family === "tron" + ? `tron:0x${value.toString(16)}` + : `eip155:${value.toString(10)}`; +} + +export function fromX402Network(id: string): X402NetworkIdentity { + const match = X402_ID.exec(id.trim().toLowerCase()); + if (!match) { + throw new UsageError("unsupported_network", `not an x402 network id: ${id}`); + } + return { + family: match[1] === "tron" ? "tron" : "evm", + chainId: BigInt(match[2]!).toString(10), + }; +}