@integraledger/lcp-binding-evm-x402 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The two mandatory x402 checks.
3
+ *
4
+ * 1. **Asset-transfer-method filter.** The atrHash-as-nonce weld rides ONLY on EIP-3009
5
+ * `transferWithAuthorization`, whose `nonce` is a payer-controlled 32-byte field committed on-chain in
6
+ * `AuthorizationUsed`. x402's `exact` scheme on EVM defines **three** methods, verified at
7
+ * `x402-foundation/x402@1fec3aa04e41`:
8
+ *
9
+ * 1. `eip3009` — tokens with native `transferWithAuthorization` (the default when `extra` omits it)
10
+ * 2. `permit2` — tokens without it, via a proxy and the canonical Permit2 contract
11
+ * 3. `erc7710` — smart accounts with delegation support
12
+ *
13
+ * Only the first exposes a payer-controlled nonce. Under `permit2` the value rides an off-chain EIP-712
14
+ * witness, and under `erc7710` the payment is authorized by a delegation the payer's smart account
15
+ * granted — in neither case is there a 32-byte field on the settlement for the weld to occupy. An offer
16
+ * requesting either would settle a payment that silently DROPS the binding, so this refuses instead.
17
+ *
18
+ * The rule is not Permit2-specific and the naming here is deliberate: it is "the method must be the one
19
+ * that carries a payer nonce". A refusal naming only one of the two rejected methods would report a
20
+ * correct decision for the wrong reason, which is what an operator then debugs.
21
+ *
22
+ * The settlement-side spender/witness proxy detection is the on-chain extension that lands once a
23
+ * harness run closes it; it is NOT fabricated here.
24
+ *
25
+ * 2. **Payment-flow filter.** x402 §6.1 defines three flows and reserves `extra.paymentFlow` as a
26
+ * protocol key: `authorization` (verify → resource → settle), `upfront` (settle → resource) and
27
+ * `escrow` (settle → resource → settle). "When the resolved payment flow is not `authorization`,
28
+ * `PaymentRequired` `accepts[].extra.paymentFlow` MUST be present", and "Clients MUST NOT construct a
29
+ * payment for a `paymentFlow` they do not recognize."
30
+ *
31
+ * This weld survives `upfront` untouched — one settlement, one EIP-3009 authorization, one nonce.
32
+ *
33
+ * `escrow` settles TWICE, and the scheme's own table marks `eip3009` "One-time use", so a single nonce
34
+ * cannot carry both the deposit and the final charge. **That is a statement about THIS carrier, not a
35
+ * judgement about the flow.** A two-phase settlement is exactly the case the record is supposed to
36
+ * survive, and there is a binding for it: `@integraledger/lcp-binding-evm-escrow` welds
37
+ * `PaymentInfo.salt`, which is recoverable from BOTH the authorization and the capture artifact and
38
+ * joins them on `paymentInfoHash`. So this answers "not this binding, that one" and names it — it does
39
+ * not tell a deployment how to transact, which is not this project's business.
40
+ *
41
+ * 3. **Inbound `nonce == atrHash` re-challenge.** The seller-side verification that a presented
42
+ * authorization's nonce equals the atrHash it advertised. The primitive lives here; the re-challenge
43
+ * orchestration (re-issue the 402 on mismatch) belongs to the seller surface.
44
+ */
45
+
46
+ import type { Outcome, Refusal } from "@integraledger/lcp-binding-core";
47
+ import { atrHashEquals } from "@integraledger/lcp-kernel";
48
+
49
+ /** The x402 asset-transfer method an offer may request. Only `eip3009` carries the payer nonce. */
50
+ export const EIP3009_TRANSFER_METHOD = "eip3009";
51
+
52
+ /**
53
+ * The methods x402's exact-EVM scheme defines, in the scheme's own order. Listed so the refusal can say
54
+ * whether it met a method the host defines or one nobody does — a typo and an unsupported-but-real method
55
+ * are different problems for the operator reading the message.
56
+ */
57
+ export const X402_TRANSFER_METHODS: readonly string[] = [
58
+ "eip3009",
59
+ "permit2",
60
+ "erc7710",
61
+ ];
62
+
63
+ /**
64
+ * Refuse an asset-transfer method that carries no payer nonce. Returns a typed `policy-rejection`
65
+ * `Refusal` when the method is present and not `eip3009`; `null` when it is absent or `eip3009` (proceed).
66
+ *
67
+ * Absent is `null` because x402's scheme says so: "If no `assetTransferMethod` is specified in
68
+ * `PaymentRequired.extra`, clients should default to `eip3009`."
69
+ */
70
+ export function filterAssetTransferMethod(
71
+ assetTransferMethod: string | undefined,
72
+ ): Refusal | null {
73
+ if (
74
+ assetTransferMethod === undefined ||
75
+ assetTransferMethod === EIP3009_TRANSFER_METHOD
76
+ )
77
+ return null;
78
+ const known = X402_TRANSFER_METHODS.includes(assetTransferMethod);
79
+ return {
80
+ refused: true,
81
+ haltClass: "policy-rejection",
82
+ code: "x402/asset-transfer-method-unsupported",
83
+ detail: `the atrHash weld requires the eip3009 asset-transfer method, whose payer-controlled nonce carries it; got "${assetTransferMethod}"${
84
+ known
85
+ ? " — a method x402 defines, but one that exposes no 32-byte payer field for the weld"
86
+ : " — not a method x402's exact-EVM scheme defines (eip3009, permit2, erc7710)"
87
+ }. Refusing rather than settling unwelded.`,
88
+ };
89
+ }
90
+
91
+ /** The x402 payment flows, §6.1. `authorization` is the default when `extra.paymentFlow` is omitted. */
92
+ export const X402_PAYMENT_FLOWS: readonly string[] = [
93
+ "authorization",
94
+ "upfront",
95
+ "escrow",
96
+ ];
97
+
98
+ /**
99
+ * Report a payment flow this weld cannot ride. `null` means proceed.
100
+ *
101
+ * Absent is `null` because §6.1 makes `authorization` the resolved default, and it is also the flow whose
102
+ * ordering the rest of this package assumes: verify → resource → settle.
103
+ *
104
+ * The `escrow` answer is a ROUTING answer, not a policy one. Which settlement shape a party chooses is
105
+ * theirs; what this project owes them is a record that survives it, and for two-phase settlement that
106
+ * record is `binding-evm-escrow`'s, not this one's.
107
+ */
108
+ export function filterPaymentFlow(
109
+ paymentFlow: string | undefined,
110
+ ): Refusal | null {
111
+ if (paymentFlow === undefined || paymentFlow === "authorization") return null;
112
+ if (paymentFlow === "upfront") return null;
113
+ const known = X402_PAYMENT_FLOWS.includes(paymentFlow);
114
+ return {
115
+ refused: true,
116
+ haltClass: "policy-rejection",
117
+ code: known
118
+ ? "x402/weld-not-carried-by-this-binding"
119
+ : "x402/payment-flow-unrecognized",
120
+ detail: known
121
+ ? `the escrow payment flow settles twice (settle -> resource -> settle) and x402 marks eip3009 "One-time use", so this binding's nonce can carry only one of the two. Use @integraledger/lcp-binding-evm-escrow for a two-phase settlement: it welds PaymentInfo.salt, which is recoverable from BOTH the authorization and the capture artifact and joins them on paymentInfoHash. Which settlement shape you use is your choice; this is about which binding carries the record across it.`
122
+ : `unrecognized x402 paymentFlow "${paymentFlow}" — §6.1 defines ${X402_PAYMENT_FLOWS.join(", ")}, and clients MUST NOT construct a payment for a flow they do not recognize`,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The inbound re-challenge: does a presented authorization nonce equal the advertised atrHash?
128
+ * Compared as DECODED BYTES per LCP §2.5, so the two any-case spellings of one hash agree; returns the
129
+ * lowercased nonce on match, else a `verification-failure`
130
+ * `Refusal` (the caller re-issues the 402 — never settles an unwelded payment).
131
+ */
132
+ export function verifyInboundNonce(
133
+ presentedNonce: string,
134
+ expectedAtrHash: string,
135
+ ): Outcome<`0x${string}`> {
136
+ // Decoded-byte comparison per LCP §2.5. Fails CLOSED: a presented nonce that is not a well-formed
137
+ // atrHash is refused rather than case-folded into a string match against a malformed expectation.
138
+ if (!atrHashEquals(presentedNonce, expectedAtrHash))
139
+ return {
140
+ refused: true,
141
+ haltClass: "verification-failure",
142
+ code: "x402/nonce-mismatch",
143
+ detail: `presented nonce ${presentedNonce} does not equal the advertised atrHash ${expectedAtrHash}`,
144
+ };
145
+ return { ok: true, value: presentedNonce.toLowerCase() as `0x${string}` };
146
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Verified per-deployment `X402AdapterConfig` values for the LCP canonical EVM binding
3
+ * `LCP-X402-EVM-NONCE-1`.
4
+ *
5
+ * **Why this file exists.** `tokenName` and `tokenVersion` are the EIP-712 domain of the USDC contract the
6
+ * authorization is signed against, and they are NOT constant across Circle's deployments: Base Sepolia and
7
+ * Monad report `name() = "USDC"`, while Base mainnet and Avalanche report `name() = "USD Coin"`. A config
8
+ * copied from one chain to another therefore produces a WRONG domain separator, and every authorization
9
+ * signed under it fails verification at the token — silently, from the seller's point of view, because the
10
+ * signature is well-formed and simply does not recover to the payer. That is the exact failure this file
11
+ * removes: each entry below was read from its own deployed contract, never inherited from a sibling.
12
+ *
13
+ * Every value here is reproducible in one call, and the pinned domain separators in
14
+ * the repository's pinned domain-separator tests are what hold them honest:
15
+ *
16
+ * cast call <asset> "name()(string)" --rpc-url <rpc>
17
+ * cast call <asset> "version()(string)" --rpc-url <rpc>
18
+ * cast call <asset> "DOMAIN_SEPARATOR()(bytes32)" --rpc-url <rpc>
19
+ *
20
+ * **AN UPSTREAM MISMATCH, RECORDED HERE BECAUSE IT IS THE KIND THAT FAILS SILENTLY.** x402's own Go
21
+ * implementation disagrees with the chain about Monad. Verified 2026-08-08 at
22
+ * `x402-foundation/x402@1fec3aa04e41`, `go/mechanisms/evm/constants.go`: the `"eip155:143"` entry names
23
+ * the SAME asset address as the `monad` entry below — `0x754704Bc059F8C67012fEd69BC8A327a5aafb603` — and
24
+ * gives it EIP-712 domain `Name: "USD Coin"`. The deployed contract's `name()` returns `"USDC"`, which is
25
+ * what the entry below carries and what the pinned `DOMAIN_SEPARATOR()` assertion in this repository holds
26
+ * it to.
27
+ *
28
+ * The chain is the authority: the domain separator is computed by the token contract, so a signer using
29
+ * "USD Coin" against a contract reporting "USDC" produces an authorization that simply does not recover to
30
+ * the payer. That is the silent failure this whole file exists to prevent, and here it is upstream.
31
+ *
32
+ * **REPORTED UPSTREAM 2026-08-08:** x402-foundation/x402#3102, with the `cast call` reproduction and the
33
+ * observation that USDC deployments are not uniform on this — Base Sepolia and Monad report "USDC" while
34
+ * Base mainnet and Avalanche report "USD Coin", so a value copied between chains yields a wrong domain
35
+ * separator rather than an obvious error. Our entry stays as measured whatever upstream does: the token
36
+ * contract computes the separator, so the chain is the authority.
37
+ *
38
+ * **What this file is not.** It is not an endorsement of a rail, an allowlist, or a supported-chain list.
39
+ * `createX402Adapter` takes any `X402AdapterConfig`, and a deployment absent here is not thereby refused —
40
+ * it simply has not been read off-chain by us. Adding a chain means adding a verified row, nothing more.
41
+ */
42
+ import type { X402AdapterConfig } from "./adapter.js";
43
+
44
+ /** The deployments whose EIP-712 domain values have been read from their own USDC contract. */
45
+ export type X402DeploymentName =
46
+ | "base"
47
+ | "base-sepolia"
48
+ | "avalanche"
49
+ | "monad";
50
+
51
+ /**
52
+ * Verified 2026-07-30. `fromBlock` is deliberately absent: an enumeration lower bound is an operational
53
+ * choice per deployment, not a property of the token, and defaulting one here would silently narrow a
54
+ * caller's history scan.
55
+ */
56
+ const DEPLOYMENTS: Record<X402DeploymentName, X402AdapterConfig> = {
57
+ base: {
58
+ chainId: 8453,
59
+ asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
60
+ tokenName: "USD Coin",
61
+ tokenVersion: "2",
62
+ },
63
+ // The chain the x402 weld is live-proven on. Note the name differs from Base MAINNET above — the two
64
+ // Base deployments do not share an EIP-712 domain, which is the sharpest form of this file's hazard.
65
+ "base-sepolia": {
66
+ chainId: 84532,
67
+ asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
68
+ tokenName: "USDC",
69
+ tokenVersion: "2",
70
+ },
71
+ avalanche: {
72
+ chainId: 43114,
73
+ asset: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",
74
+ tokenName: "USD Coin",
75
+ tokenVersion: "2",
76
+ },
77
+ monad: {
78
+ chainId: 143,
79
+ asset: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603",
80
+ tokenName: "USDC",
81
+ tokenVersion: "2",
82
+ },
83
+ };
84
+
85
+ /**
86
+ * The verified config for a known deployment. Throws on an unknown name (fail-fast): there is no sane
87
+ * default, and guessing a domain would produce signatures that verify nowhere.
88
+ */
89
+ export function getX402Deployment(name: string): X402AdapterConfig {
90
+ // The guard is folded INTO the lookup rather than sitting in front of it: a separate `hasOwn` early
91
+ // return would make the `=== undefined` check below unreachable, i.e. dead code.
92
+ const config = Object.hasOwn(DEPLOYMENTS, name)
93
+ ? DEPLOYMENTS[name as X402DeploymentName]
94
+ : undefined;
95
+ if (config === undefined)
96
+ throw new Error(
97
+ `unknown x402 deployment "${name}" — known: ${Object.keys(DEPLOYMENTS).join(", ")}. ` +
98
+ "A deployment's tokenName/tokenVersion must be read from its own USDC contract (name()/version()); " +
99
+ "inheriting another chain's values yields a wrong EIP-712 domain and unverifiable signatures.",
100
+ );
101
+ return config;
102
+ }
103
+
104
+ /** The known deployment names, for callers that enumerate rather than look up. */
105
+ export function x402DeploymentNames(): X402DeploymentName[] {
106
+ return Object.keys(DEPLOYMENTS) as X402DeploymentName[];
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export {
2
+ createX402Adapter,
3
+ type X402AdapterConfig,
4
+ type X402Proposal,
5
+ type X402ProposalContext,
6
+ } from "./adapter.js";
7
+ export {
8
+ EIP3009_TRANSFER_METHOD,
9
+ filterAssetTransferMethod,
10
+ filterPaymentFlow,
11
+ verifyInboundNonce,
12
+ X402_PAYMENT_FLOWS,
13
+ X402_TRANSFER_METHODS,
14
+ } from "./asset-transfer-method-filter.js";
15
+ export {
16
+ getX402Deployment,
17
+ type X402DeploymentName,
18
+ x402DeploymentNames,
19
+ } from "./constants.js";
20
+ export { X402_MANIFEST } from "./manifest.js";
@@ -0,0 +1,37 @@
1
+ import type { BindingManifest } from "@integraledger/lcp-binding-core";
2
+
3
+ /**
4
+ * The x402 `exact` / EIP-3009 binding manifest (LCP `LCP-X402-EVM-NONCE-1`).
5
+ *
6
+ * **pattern = "native-field"** — the atrHash rides the EIP-3009 `nonce`, an EXISTING protocol field
7
+ * the payer signs over; there is no derivation and no overlay contract. Off-canonical Native Field per
8
+ * LCP §8.3.1 / Appendix B.1. This is authored fresh and correct — an archived
9
+ * declaration says `id-reuse` / non-recoverable / non-indexable, a KNOWN-BAD artifact: it read MPP-EVM's
10
+ * Appendix-C.1 derivation-MUST (a property of MPP's method spec) onto the x402 path, where no derivation
11
+ * exists. Do NOT reconcile these values against that archive — "fixing" them reintroduces the bug.
12
+ */
13
+ export const X402_MANIFEST: BindingManifest = {
14
+ rail: "evm:x402",
15
+ protocol: "x402",
16
+ pattern: "native-field",
17
+ nativeField: "eip3009.nonce",
18
+ recovery: {
19
+ onChain: true,
20
+ zeroPartyRecoverable: true,
21
+ forwardIndexable: true,
22
+ },
23
+ assetBinding: "filtered", // recover reads the token's own AuthorizationUsed log — the record proves THIS asset moved
24
+ successGate: "structural", // a reverted tx emits no logs, so the AuthorizationUsed weld event cannot exist
25
+ indexing: "nonce-topic:AuthorizationUsed",
26
+ finality: {
27
+ reversible: false,
28
+ note: "instant, final settlement (EIP-3009) — no on-rail reversal; recourse is the record's elected forum, and finality is never represented as dispute resolution (PAY-3/RCS-5) The atrHash welded as the EIP-3009 nonce MUST be per-transaction: an EIP-3009 nonce is one-time use, so one payer paying twice under the same ATR reproduces the same authorization and the token rejects the second with no LCP-level diagnostic. LCP §6.1 wants a per-transaction ATR anyway.",
29
+ },
30
+ // The payer's EIP-3009 signature commits to the nonce (= atrHash): signature-grade weld.
31
+ weldGrades: { ERC3009: "signature" },
32
+ offCanonical: { profile: "integra-x402-nonce-v1" }, // named profile
33
+ // "welded-settled", not "settled": on this rail the two are one event. The nonce IS the atrHash, so the
34
+ // settlement that fires `AuthorizationUsed` welds in the same log — there is no settled-but-unwelded
35
+ // state to name. Other rails say "settled" because their weld is a payload a settlement can lack.
36
+ lifecycleStates: ["proposed", "welded-settled"],
37
+ };