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), + }; +}