Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ts/docs/machine-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
223 changes: 223 additions & 0 deletions ts/src/adapters/outbound/x402/signer-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
153 changes: 153 additions & 0 deletions ts/src/adapters/outbound/x402/signer-bridge.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
signTransaction(tx: unknown): Promise<unknown>;
}

/** 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;
},
};
}
1 change: 1 addition & 0 deletions ts/src/application/contracts/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./execution-policy.js";
export * from "./execution-scope.js";
export * from "./progress.js";
export * from "./x402-payer.js";
Loading