@integraledger/lcp-binding-evm-escrow 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.
package/src/adapter.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The Commerce Payments escrow WeldAdapter: the atrHash rides `PaymentInfo.salt`
3
+ * (`salt = uint256(atrHash)`), proven on-chain. `recover`/`enumerate`
4
+ * decode the cleartext `PaymentInfo` in `PaymentAuthorized`/`PaymentCharged` event data (the WLD-3
5
+ * event-data scan — `paymentInfoHash` is the indexed topic, `salt` is not). viem lives here.
6
+ */
7
+ import type {
8
+ LifecycleTransition,
9
+ Outcome,
10
+ SettlementRef,
11
+ VerifierPorts,
12
+ WeldAdapter,
13
+ } from "@integraledger/lcp-binding-core";
14
+ import { refOf } from "@integraledger/lcp-binding-evm-common";
15
+ import { type Abi, decodeEventLog, type Log, parseAbi } from "viem";
16
+ import { AUTH_CAPTURE_ESCROW } from "./collectors.js";
17
+ import { type EscrowEventName, EVENT_TO_STATE } from "./lifecycle.js";
18
+ import { ESCROW_MANIFEST } from "./manifest.js";
19
+
20
+ const PI_TUPLE =
21
+ "(address operator, address payer, address receiver, address token, uint120 maxAmount, uint48 preApprovalExpiry, uint48 authorizationExpiry, uint48 refundExpiry, uint16 minFeeBps, uint16 maxFeeBps, address feeReceiver, uint256 salt)";
22
+
23
+ /** The six escrow lifecycle events. `PaymentAuthorized`/`PaymentCharged` carry the cleartext `PaymentInfo`
24
+ * (→ salt → atrHash); the rest join on the indexed `paymentInfoHash`. */
25
+ export const ESCROW_EVENTS_ABI: Abi = parseAbi([
26
+ `event PaymentAuthorized(bytes32 indexed paymentInfoHash, ${PI_TUPLE} paymentInfo, uint256 amount, address tokenCollector)`,
27
+ `event PaymentCharged(bytes32 indexed paymentInfoHash, ${PI_TUPLE} paymentInfo, uint256 amount, address tokenCollector, uint16 feeBps, address feeReceiver)`,
28
+ "event PaymentCaptured(bytes32 indexed paymentInfoHash, uint256 amount, uint16 feeBps, address feeReceiver)",
29
+ "event PaymentVoided(bytes32 indexed paymentInfoHash, uint256 amount)",
30
+ "event PaymentReclaimed(bytes32 indexed paymentInfoHash, uint256 amount)",
31
+ "event PaymentRefunded(bytes32 indexed paymentInfoHash, uint256 amount, address tokenCollector)",
32
+ ]);
33
+
34
+ /** `AuthCaptureEscrow.PaymentInfo` — the pre-settlement artifact. `salt` carries the atrHash. */
35
+ export interface PaymentInfo {
36
+ operator: `0x${string}`;
37
+ payer: `0x${string}`;
38
+ receiver: `0x${string}`;
39
+ token: `0x${string}`;
40
+ maxAmount: bigint;
41
+ preApprovalExpiry: number;
42
+ authorizationExpiry: number;
43
+ refundExpiry: number;
44
+ minFeeBps: number;
45
+ maxFeeBps: number;
46
+ feeReceiver: `0x${string}`;
47
+ salt: bigint;
48
+ }
49
+
50
+ /** `salt = uint256(atrHash)` — the 32-byte atrHash reinterpreted as a uint256. */
51
+ export function saltFromAtrHash(atrHash: `0x${string}`): bigint {
52
+ return BigInt(atrHash);
53
+ }
54
+ /** Recover the atrHash from a `PaymentInfo.salt` — the reverse (lowercase 0x, 32 bytes). */
55
+ export function atrHashFromSalt(salt: bigint): `0x${string}` {
56
+ return `0x${salt.toString(16).padStart(64, "0")}`;
57
+ }
58
+
59
+ /** The per-payment proposal inputs (everything but `salt`, which the adapter fills from the atrHash). */
60
+ export type EscrowProposalContext = Omit<PaymentInfo, "salt">;
61
+ /** What `propose` returns: the `PaymentInfo` to submit, with `salt` already set from the atrHash. A
62
+ * single-field object rather than a bare `PaymentInfo` so the return can gain siblings without breaking
63
+ * callers. This package proposes and reads — it never operates the escrow. */
64
+ export interface EscrowProposal {
65
+ paymentInfo: PaymentInfo;
66
+ }
67
+
68
+ /** How to point the adapter at a deployment. `chainId` is required; the other two default — `escrow` to
69
+ * the canonical deterministic `AuthCaptureEscrow`, `fromBlock` to the full history. Widen `fromBlock`
70
+ * knowingly: enumeration from `earliest` is a full-history scan on a busy chain. */
71
+ export interface EscrowAdapterConfig {
72
+ chainId: number;
73
+ /** The `AuthCaptureEscrow` address; defaults to the canonical deterministic deployment. */
74
+ escrow?: `0x${string}`;
75
+ /** Enumeration lower bound; defaults to the full history (`earliest`). */
76
+ fromBlock?: bigint;
77
+ }
78
+
79
+ /**
80
+ * One decoded escrow lifecycle event, INCLUDING the asset the payment moved.
81
+ *
82
+ * The asset fields are carried rather than dropped, and that is what makes `assetBinding: "carried"` a
83
+ * true claim: the axis asks whether a CONSUMER can reach the asset the weld is attached to, not merely
84
+ * whether the chain recorded it. Decoding `salt` and `amount` while discarding the rest of `PaymentInfo`
85
+ * would leave the manifest declaring an asset binding nobody could check.
86
+ */
87
+ export interface DecodedEscrowLog {
88
+ name: EscrowEventName;
89
+ /** The atrHash weld, as the raw `PaymentInfo.salt` uint256. Absent on events carrying no `PaymentInfo`. */
90
+ salt?: bigint;
91
+ amount: bigint;
92
+ /** ERC-20 the payment moved. Absent on events carrying no `PaymentInfo`. */
93
+ token?: `0x${string}`;
94
+ /** The paying account. Absent on events carrying no `PaymentInfo`. */
95
+ payer?: `0x${string}`;
96
+ /** The receiving account. Absent on events carrying no `PaymentInfo`. */
97
+ receiver?: `0x${string}`;
98
+ logIndex: number | null;
99
+ txHash: `0x${string}` | null;
100
+ }
101
+
102
+ /**
103
+ * Decode every escrow lifecycle event in a log set (ignoring anything that is not one).
104
+ *
105
+ * Exported because it is the only way a consumer reaches the asset behind the weld: `observe` returns
106
+ * `LifecycleTransition[]`, a shape fixed by `binding-core` that has no room for `token`/`payer`/`receiver`.
107
+ * A caller checking that a settlement moved the asset its record names calls this directly.
108
+ */
109
+ export function decodeEscrowLogs(
110
+ logs: readonly Log[],
111
+ escrow: string,
112
+ ): DecodedEscrowLog[] {
113
+ const want = escrow.toLowerCase();
114
+ const out: DecodedEscrowLog[] = [];
115
+ for (const log of logs) {
116
+ if (log.address.toLowerCase() !== want) continue;
117
+ try {
118
+ const decoded = decodeEventLog({
119
+ abi: ESCROW_EVENTS_ABI,
120
+ data: log.data,
121
+ topics: log.topics,
122
+ });
123
+ const args = decoded.args as unknown as {
124
+ paymentInfo?: {
125
+ salt: bigint;
126
+ token: `0x${string}`;
127
+ payer: `0x${string}`;
128
+ receiver: `0x${string}`;
129
+ };
130
+ amount: bigint;
131
+ };
132
+ out.push({
133
+ name: decoded.eventName as unknown as EscrowEventName,
134
+ ...(args.paymentInfo !== undefined
135
+ ? {
136
+ salt: args.paymentInfo.salt,
137
+ token: args.paymentInfo.token,
138
+ payer: args.paymentInfo.payer,
139
+ receiver: args.paymentInfo.receiver,
140
+ }
141
+ : {}),
142
+ amount: args.amount,
143
+ logIndex: log.logIndex,
144
+ txHash: log.transactionHash,
145
+ });
146
+ } catch {
147
+ // not an escrow lifecycle event — ignore
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** Construct the escrow WeldAdapter for one chain. */
154
+ export function createEscrowAdapter(config: EscrowAdapterConfig): WeldAdapter {
155
+ const escrow = config.escrow ?? AUTH_CAPTURE_ESCROW;
156
+ return {
157
+ manifest: ESCROW_MANIFEST,
158
+
159
+ async propose(
160
+ atrHash: `0x${string}`,
161
+ ctx: unknown,
162
+ ): Promise<Outcome<EscrowProposal>> {
163
+ const c = ctx as EscrowProposalContext;
164
+ // The salt is filled from the atrHash — never re-derived. No value-level Refusal
165
+ // on this path; a malformed atrHash is a programming error surfaced by BigInt() (fail-fast).
166
+ const paymentInfo: PaymentInfo = { ...c, salt: saltFromAtrHash(atrHash) };
167
+ return { ok: true, value: { paymentInfo } };
168
+ },
169
+
170
+ async recover(
171
+ ref: SettlementRef,
172
+ ports: VerifierPorts,
173
+ ): Promise<Outcome<`0x${string}`>> {
174
+ const logs = (await ports.chain.getTransactionLogs(ref)) as Log[];
175
+ // Only PaymentAuthorized/PaymentCharged carry the cleartext PaymentInfo (→ salt → atrHash).
176
+ const salted = decodeEscrowLogs(logs, escrow).filter(
177
+ (e): e is typeof e & { salt: bigint } => e.salt !== undefined,
178
+ );
179
+ if (salted.length === 0)
180
+ return {
181
+ refused: true,
182
+ haltClass: "verification-failure",
183
+ code: "escrow/no-recoverable-event",
184
+ detail: `no PaymentAuthorized/PaymentCharged with a cleartext PaymentInfo in this settlement`,
185
+ };
186
+ // Disambiguate by logIndex when the ref pins one — and a pinned index matching NO salt-bearing event
187
+ // is a failure, never a fall-through to the first. One escrow transaction can authorize or charge
188
+ // several independent payments, each with its own salt.
189
+ if (ref.logIndex !== undefined) {
190
+ const match = salted.find((e) => e.logIndex === ref.logIndex);
191
+ if (match === undefined)
192
+ return {
193
+ refused: true,
194
+ haltClass: "verification-failure",
195
+ code: "escrow/log-index-not-found",
196
+ detail: `no salt-bearing escrow event at logIndex ${ref.logIndex} in settlement ${ref.txHash}`,
197
+ };
198
+ return { ok: true, value: atrHashFromSalt(match.salt) };
199
+ }
200
+ // Unpinned. This used to take the FIRST salt-bearing event, which silently answered one payment's
201
+ // atrHash for a transaction that welded several — the same first-wins defect x402's `recover` carried,
202
+ // and the one tempo-mpp refuses by name. Distinctness is the test: the same payment observed through
203
+ // both PaymentAuthorized and PaymentCharged carries one salt and is not ambiguous.
204
+ const distinct = new Set(salted.map((e) => e.salt));
205
+ if (distinct.size > 1)
206
+ return {
207
+ refused: true,
208
+ haltClass: "verification-failure",
209
+ code: "escrow/ambiguous-settlement",
210
+ detail: `settlement ${ref.txHash} carries ${distinct.size} payments with different salts — pin one with ref.logIndex rather than choosing for the caller`,
211
+ };
212
+ return {
213
+ ok: true,
214
+ value: atrHashFromSalt((salted[0] as (typeof salted)[number]).salt),
215
+ };
216
+ },
217
+
218
+ async observe(
219
+ ref: SettlementRef,
220
+ ports: VerifierPorts,
221
+ ): Promise<Outcome<LifecycleTransition[]>> {
222
+ const logs = (await ports.chain.getTransactionLogs(ref)) as Log[];
223
+ const events = decodeEscrowLogs(logs, escrow);
224
+ if (events.length === 0) return { ok: true, value: [] };
225
+ const at = await ports.chain.blockTime(ref);
226
+ return {
227
+ ok: true,
228
+ value: events.map((e) => ({
229
+ state: EVENT_TO_STATE[e.name],
230
+ at,
231
+ ref: refOf(ref.chainId, ref.txHash, e.logIndex),
232
+ })),
233
+ };
234
+ },
235
+
236
+ async enumerate(
237
+ atrHash: `0x${string}`,
238
+ ports: VerifierPorts,
239
+ ): Promise<SettlementRef[]> {
240
+ // Event-data scan (salt is NOT an indexed topic): fetch the salt-bearing events over the range and
241
+ // filter by decoded salt == uint256(atrHash). paymentInfoHash is the only indexed key, so there is
242
+ // no topic filter for salt — this is the manifest's declared "event-data-scan:paymentInfoHash".
243
+ const want = saltFromAtrHash(atrHash);
244
+ const logs = (await ports.chain.getLogs({
245
+ address: escrow,
246
+ events: [ESCROW_EVENTS_ABI[0], ESCROW_EVENTS_ABI[1]], // PaymentAuthorized, PaymentCharged
247
+ fromBlock: config.fromBlock ?? "earliest",
248
+ toBlock: "latest",
249
+ })) as Log[];
250
+ return decodeEscrowLogs(logs, escrow)
251
+ .filter((e) => e.salt === want)
252
+ .map((e) => refOf(config.chainId, e.txHash ?? undefined, e.logIndex));
253
+ },
254
+ };
255
+ }
package/src/calls.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The escrow KEY derivations — the off-chain `getHash` (== the event's indexed `paymentInfoHash`) and
3
+ * the payer-agnostic nonce, so a settlement can be keyed and joined without importing viem
4
+ * downstream — viem stays here. There are no calldata encoders in this module and none anywhere in
5
+ * this package: escrow is not our product, so this binding reads the mechanism and never drives it.
6
+ * (This module has no calldata encoders: they were removed
7
+ * with the escrow-operation retirement and the headline was not.) `encodeAbiParameters` below is
8
+ * hashing input, not transaction input.
9
+ *
10
+ * Every struct field and signature is transcribed from the DEPLOYED `AuthCaptureEscrow` at
11
+ * base/commerce-payments @ 98b592b (the pinned deployment, NOT repo HEAD — the charge/capture event ABI
12
+ * drift that shipped a bug earlier came from reading HEAD).
13
+ *
14
+ * `getHashOffchain` replicates the on-chain `getHash` byte-identically, so the settlement key is
15
+ * derivable BEFORE the authorizing tx confirms rather than only from the event. Call it with the real
16
+ * `payer` for the settlement key; call it with `payer = 0` for the ERC-3009 `ReceiveWithAuthorization`
17
+ * nonce the buyer signs (the "payer-agnostic" nonce).
18
+ */
19
+ import { encodeAbiParameters, keccak256, stringToHex } from "viem";
20
+ import type { PaymentInfo } from "./adapter.js";
21
+ import { AUTH_CAPTURE_ESCROW } from "./collectors.js";
22
+
23
+ /** The PaymentInfo tuple components, in the DEPLOYED struct's field order (98b592b). */
24
+ const PI_COMPONENTS = {
25
+ type: "tuple",
26
+ components: [
27
+ { name: "operator", type: "address" },
28
+ { name: "payer", type: "address" },
29
+ { name: "receiver", type: "address" },
30
+ { name: "token", type: "address" },
31
+ { name: "maxAmount", type: "uint120" },
32
+ { name: "preApprovalExpiry", type: "uint48" },
33
+ { name: "authorizationExpiry", type: "uint48" },
34
+ { name: "refundExpiry", type: "uint48" },
35
+ { name: "minFeeBps", type: "uint16" },
36
+ { name: "maxFeeBps", type: "uint16" },
37
+ { name: "feeReceiver", type: "address" },
38
+ { name: "salt", type: "uint256" },
39
+ ],
40
+ } as const;
41
+
42
+ /** keccak256 of the PaymentInfo typehash STRING (== the deployed PAYMENT_INFO_TYPEHASH, 0xae68…6591). */
43
+ export const PAYMENT_INFO_TYPEHASH: `0x${string}` = keccak256(
44
+ stringToHex(
45
+ "PaymentInfo(address operator,address payer,address receiver,address token,uint120 maxAmount,uint48 preApprovalExpiry,uint48 authorizationExpiry,uint48 refundExpiry,uint16 minFeeBps,uint16 maxFeeBps,address feeReceiver,uint256 salt)",
46
+ ),
47
+ );
48
+
49
+ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const;
50
+
51
+ /**
52
+ * The off-chain `getHash(paymentInfo)` — `keccak256(abi.encode(chainId, escrow, keccak256(abi.encode(
53
+ * TYPEHASH, paymentInfo))))`. Equals the event's indexed `paymentInfoHash` (the deployed `authorize`/
54
+ * `charge` emit `getHash(paymentInfo)` as that topic) and the settlement key. This matches
55
+ * the on-chain view byte-for-byte (incl. the fee-bearing case).
56
+ */
57
+ export function getHashOffchain(params: {
58
+ chainId: number;
59
+ escrow?: `0x${string}`;
60
+ paymentInfo: PaymentInfo;
61
+ }): `0x${string}` {
62
+ const escrow = params.escrow ?? AUTH_CAPTURE_ESCROW;
63
+ const inner = keccak256(
64
+ encodeAbiParameters(
65
+ [{ type: "bytes32" }, PI_COMPONENTS],
66
+ [PAYMENT_INFO_TYPEHASH, params.paymentInfo],
67
+ ),
68
+ );
69
+ return keccak256(
70
+ encodeAbiParameters(
71
+ [{ type: "uint256" }, { type: "address" }, { type: "bytes32" }],
72
+ [BigInt(params.chainId), escrow, inner],
73
+ ),
74
+ );
75
+ }
76
+
77
+ /** The settlement key / event `paymentInfoHash` for a PaymentInfo (alias of `getHashOffchain`). */
78
+ export function paymentInfoHash(params: {
79
+ chainId: number;
80
+ escrow?: `0x${string}`;
81
+ paymentInfo: PaymentInfo;
82
+ }): `0x${string}` {
83
+ return getHashOffchain(params);
84
+ }
85
+
86
+ /**
87
+ * The ERC-3009 `ReceiveWithAuthorization` nonce the buyer signs — `getHash(paymentInfo with payer = 0)`
88
+ * (the "payer-agnostic" hash; the collector fills the real payer from the recovered signature).
89
+ */
90
+ export function payerAgnosticNonce(params: {
91
+ chainId: number;
92
+ escrow?: `0x${string}`;
93
+ paymentInfo: PaymentInfo;
94
+ }): `0x${string}` {
95
+ return getHashOffchain({
96
+ ...params,
97
+ paymentInfo: { ...params.paymentInfo, payer: ZERO_ADDRESS },
98
+ });
99
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Commerce Payments token collectors — the modular payer-authorization methods `authorize`/`charge`
3
+ * pull funds through. Addresses are the deterministic base/commerce-payments deployments (identical on
4
+ * Base Mainnet and Base Sepolia). Each carries its **weld grade** (per the manifest) and whether that
5
+ * grade is **on-chain-proven** vs characterized-from-source.
6
+ *
7
+ * The ERC3009 collector's `collectorData` IS the payer's signature (the collector runs it through
8
+ * `_handleERC6492Signature`, so a smart-wallet ERC-6492 sig works too) over the USDC
9
+ * `ReceiveWithAuthorization` with `to = <the collector>`, `value = maxAmount`, `nonce =
10
+ * getHash(paymentInfo with payer = 0)`. The signing itself is the money-path runtime's job
11
+ * this package declares the collectors, their grades, and the policy.
12
+ */
13
+ import type { Refusal, WeldGrade } from "@integraledger/lcp-binding-core";
14
+
15
+ /** The four Commerce Payments collectors — the payer-authorization methods funds are pulled through.
16
+ * Naming one is not the same as being able to USE it here: the shipped policy accepts only collectors
17
+ * whose weld grade is on-chain-proven, and today that is `ERC3009` alone. */
18
+ export type CollectorName =
19
+ | "ERC3009"
20
+ | "Permit2"
21
+ | "SpendPermission"
22
+ | "PreApproval";
23
+
24
+ /** One collector's declaration: where it is deployed, what weld grade it earns, and — separately —
25
+ * whether that grade has been PROVEN by an on-chain run rather than read off the pinned source.
26
+ * `proven: false` is not a doubt about the code; it records that nobody has watched it happen. */
27
+ export interface EscrowCollector {
28
+ name: CollectorName;
29
+ address: `0x${string}`;
30
+ grade: WeldGrade;
31
+ /** True iff the grade is proven by an on-chain run (not merely read from the pinned source). */
32
+ proven: boolean;
33
+ }
34
+
35
+ /** The base/commerce-payments collector deployments (Base Mainnet == Base Sepolia, deterministic). */
36
+ export const COLLECTORS: Record<CollectorName, EscrowCollector> = {
37
+ ERC3009: {
38
+ name: "ERC3009",
39
+ address: "0x0E3dF9510de65469C4518D7843919c0b8C7A7757",
40
+ grade: "signature",
41
+ proven: true,
42
+ },
43
+ Permit2: {
44
+ name: "Permit2",
45
+ address: "0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26",
46
+ grade: "signature",
47
+ proven: false,
48
+ },
49
+ SpendPermission: {
50
+ name: "SpendPermission",
51
+ address: "0x8d9F34934dc9619e5DC3Df27D0A40b4A744E7eAa",
52
+ grade: "signature",
53
+ proven: false,
54
+ },
55
+ PreApproval: {
56
+ name: "PreApproval",
57
+ address: "0x1b77ABd71FCD21fbe2398AE821Aa27D1E6B94bC6",
58
+ grade: "tx",
59
+ proven: false,
60
+ },
61
+ };
62
+
63
+ /** The `AuthCaptureEscrow` (Base Mainnet == Base Sepolia). */
64
+ export const AUTH_CAPTURE_ESCROW =
65
+ "0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff" as const;
66
+
67
+ /** The declaration for one collector. Total over the union, so it cannot fail — the acceptability check
68
+ * is a separate call, and reaching a collector here is not permission to use it. */
69
+ export function getCollector(name: CollectorName): EscrowCollector {
70
+ return COLLECTORS[name];
71
+ }
72
+
73
+ /**
74
+ * Enforce a **signature-grade, on-chain-proven** collector policy (fail-fast, no silent tx-grade
75
+ * fallback): refuse a `tx`-grade collector where signature-grade is required, and refuse a collector
76
+ * whose grade is not yet on-chain-proven (only ERC3009 is). Returns a `policy-rejection` Refusal
77
+ * or `null` when the collector is acceptable.
78
+ */
79
+ export function assertSignatureGrade(name: CollectorName): Refusal | null {
80
+ const c = COLLECTORS[name];
81
+ if (c.grade !== "signature")
82
+ return {
83
+ refused: true,
84
+ haltClass: "policy-rejection",
85
+ code: "escrow/tx-grade-collector",
86
+ detail: `collector ${name} is ${c.grade}-grade; a signature-grade weld is required (no silent tx-grade fallback)`,
87
+ };
88
+ if (!c.proven)
89
+ return {
90
+ refused: true,
91
+ haltClass: "policy-rejection",
92
+ code: "escrow/unproven-collector",
93
+ detail: `collector ${name}'s grade is characterized-from-source but not on-chain-proven — prove it on-chain before use`,
94
+ };
95
+ return null;
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ export {
2
+ atrHashFromSalt,
3
+ createEscrowAdapter,
4
+ type DecodedEscrowLog,
5
+ decodeEscrowLogs,
6
+ ESCROW_EVENTS_ABI,
7
+ type EscrowAdapterConfig,
8
+ type EscrowProposal,
9
+ type EscrowProposalContext,
10
+ type PaymentInfo,
11
+ saltFromAtrHash,
12
+ } from "./adapter.js";
13
+ export {
14
+ getHashOffchain,
15
+ PAYMENT_INFO_TYPEHASH,
16
+ payerAgnosticNonce,
17
+ paymentInfoHash,
18
+ } from "./calls.js";
19
+ export {
20
+ AUTH_CAPTURE_ESCROW,
21
+ assertSignatureGrade,
22
+ COLLECTORS,
23
+ type CollectorName,
24
+ type EscrowCollector,
25
+ getCollector,
26
+ } from "./collectors.js";
27
+ export {
28
+ type EscrowEventName,
29
+ type EscrowState,
30
+ EVENT_TO_STATE,
31
+ stateFor,
32
+ } from "./lifecycle.js";
33
+ export { ESCROW_MANIFEST } from "./manifest.js";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The escrow lifecycle as a discriminated union — illegal states are not constructible. The
3
+ * `charged` state is terminal for COLLECTION (the one-step charge path — no authorize/capture follows),
4
+ * but `refunded` stays reachable within `refundExpiry`, which is exactly the manifest's
5
+ * `finality: { reversible: true }`: an on-rail refund, not a capture reversal — the two answer different
6
+ * questions and both hold. Transitions join on the indexed `paymentInfoHash`.
7
+ */
8
+ export type EscrowState =
9
+ | { s: "proposed" }
10
+ | { s: "authorized"; amount: bigint }
11
+ | { s: "captured"; amount: bigint }
12
+ | { s: "charged"; amount: bigint }
13
+ | { s: "voided" }
14
+ | { s: "reclaimed" }
15
+ | { s: "refunded"; amount: bigint };
16
+
17
+ /** The six on-chain escrow events (`AuthCaptureEscrow`) that drive lifecycle transitions. */
18
+ export type EscrowEventName =
19
+ | "PaymentAuthorized"
20
+ | "PaymentCaptured"
21
+ | "PaymentCharged"
22
+ | "PaymentVoided"
23
+ | "PaymentReclaimed"
24
+ | "PaymentRefunded";
25
+
26
+ /** The lifecycle state name each event lands in (the manifest's `lifecycleStates`, minus the off-chain
27
+ * `proposed`). Used to label `observe`'s transitions. */
28
+ export const EVENT_TO_STATE: Record<EscrowEventName, EscrowState["s"]> = {
29
+ PaymentAuthorized: "authorized",
30
+ PaymentCaptured: "captured",
31
+ PaymentCharged: "charged",
32
+ PaymentVoided: "voided",
33
+ PaymentReclaimed: "reclaimed",
34
+ PaymentRefunded: "refunded",
35
+ };
36
+
37
+ /** Build the discriminated `EscrowState` for an event + its amount (amount ignored for void/reclaim). */
38
+ export function stateFor(event: EscrowEventName, amount: bigint): EscrowState {
39
+ switch (event) {
40
+ case "PaymentAuthorized":
41
+ return { s: "authorized", amount };
42
+ case "PaymentCaptured":
43
+ return { s: "captured", amount };
44
+ case "PaymentCharged":
45
+ return { s: "charged", amount };
46
+ case "PaymentVoided":
47
+ return { s: "voided" };
48
+ case "PaymentReclaimed":
49
+ return { s: "reclaimed" };
50
+ case "PaymentRefunded":
51
+ return { s: "refunded", amount };
52
+ }
53
+ }
@@ -0,0 +1,83 @@
1
+ import type { BindingManifest } from "@integraledger/lcp-binding-core";
2
+
3
+ /**
4
+ * The Commerce Payments escrow binding manifest (`AuthCaptureEscrow`, authorize→capture).
5
+ *
6
+ * **pattern = "native-field"** — the atrHash rides `PaymentInfo.salt`, an EXISTING protocol field the
7
+ * service already controls (LCP §8.3.1); no derivation, no overlay. **WLD-3 recovery is an event-data scan,
8
+ * not a topic filter:** the indexed topic is `paymentInfoHash`; `salt` rides the cleartext `PaymentInfo`
9
+ * in `PaymentAuthorized`/`PaymentCharged` event data, so `recover`/`enumerate` decode the event and read
10
+ * `salt` (proven on-chain).
11
+ *
12
+ * **recovery.forwardIndexable = false** — the criterion is enumeration bound to a GIVEN atrHash, and this
13
+ * rail cannot do it. `paymentInfoHash` is the only indexed topic, and it is a hash of the whole
14
+ * `PaymentInfo` struct: a caller holding the complete struct could topic-filter, but a caller holding only
15
+ * an atrHash cannot derive it. So `enumerate` fetches the salt-bearing events over a range and filters
16
+ * client-side on the decoded `salt` — an O(range) scan, which is exactly what `indexing:
17
+ * "event-data-scan:paymentInfoHash"` says and what `adapter.ts` does. Six siblings declare `false` for
18
+ * mechanically equivalent scans; the three that declare `true` (cardano, tempo-mpp, evm-x402) each rest on
19
+ * a real index keyed on the atrHash itself.
20
+ *
21
+ * **weldGrades are TRANSCRIBED from an on-chain proof, never authored here**: `ERC3009 =
22
+ * "signature"` is **proven on-chain** (the payer's USDC `ReceiveWithAuthorization` drives
23
+ * `authorize`); `Permit2`/`SpendPermission` = `"signature"` and `PreApproval` = `"tx"` are
24
+ * **characterized from the pinned source** (`collectors/*.sol`) and NOT yet on-chain-proven — an adapter
25
+ * refuses a collector until its grade is proven the same way (`collectors.ts`). If a run ever falsifies a
26
+ * collector, this manifest changes with it — the on-chain proof stays the authority on what may be declared.
27
+ *
28
+ * **THE atrHash MUST BE PER-TRANSACTION, AND THIS IS THE FIRST PLACE IT IS WRITTEN DOWN.** The carrier is
29
+ * `PaymentInfo.salt`, whose own specification calls it "a source of entropy to ensure unique hashes across
30
+ * different payments". An atrHash carries no entropy: it is deterministic, server-side, and identical for
31
+ * every payment made under one terms document. The consequence is on-chain and immediate — the escrow
32
+ * rejects a `PaymentInfo` it has already seen, so a repeat purchase under one ATR with the same payer,
33
+ * caps and expiries reverts. Nothing in this package can detect that, because the collision is between two
34
+ * transactions it never sees together; a deployment reusing one ATR across purchases is the failure mode,
35
+ * and the fix is a per-transaction ATR, which LCP §6.1 wants anyway.
36
+ *
37
+ * **NO `protocol`, and the silence is now examined rather than default.** x402 publishes an `auth-capture`
38
+ * scheme over this SAME contract stack (`base/commerce-payments`, `AuthCaptureEscrow`), read 2026-08-08 —
39
+ * but x402 building on a contract this binding also builds on does not make this binding x402's. There is
40
+ * no `extra` block here, no facilitator, no payload envelope: this package constructs `PaymentInfo`
41
+ * directly and settles against the contract. Declaring `protocol: "x402"` would claim conformance to a
42
+ * wire format it does not implement.
43
+ *
44
+ * The scheme is still worth reading, because it sharpens the salt requirement above. Under x402
45
+ * auth-capture the signature nonce is the **payer-agnostic** `PaymentInfo` hash — "Payer is zeroed" — and
46
+ * freshness rests entirely on the salt: "each signing call generates a fresh `bytes32` salt, so two payers
47
+ * signing concurrently produce distinct nonces with no collision risk." A deterministic salt removes the
48
+ * only thing keeping two DIFFERENT payers apart there. So a deployment that settles through an x402
49
+ * auth-capture facilitator must not weld this way at all, where a bare deployment only has to keep the ATR
50
+ * per-transaction.
51
+ */
52
+ export const ESCROW_MANIFEST: BindingManifest = {
53
+ rail: "evm:escrow",
54
+ pattern: "native-field",
55
+ nativeField: "PaymentInfo.salt",
56
+ recovery: {
57
+ onChain: true,
58
+ zeroPartyRecoverable: true,
59
+ forwardIndexable: false,
60
+ },
61
+ assetBinding: "carried", // PaymentInfo.token rides the cleartext event data — the record itself names the asset
62
+ successGate: "structural", // a reverted tx emits no logs, so the escrow weld event cannot exist
63
+ indexing: "event-data-scan:paymentInfoHash",
64
+ finality: {
65
+ reversible: true,
66
+ note: 'on-rail void/refund within refundExpiry — not dispute resolution (RCS-5); capture is not reversed, refund is a fresh on-rail remedy The atrHash welded into PaymentInfo.salt MUST be per-transaction: salt is specified as the struct\'s entropy source ("a source of entropy to ensure unique hashes across different payments") and an atrHash carries none, so a repeat purchase under one ATR with the same payer, caps and expiries produces a PaymentInfo the escrow has already seen and reverts.',
67
+ },
68
+ weldGrades: {
69
+ ERC3009: "signature",
70
+ Permit2: "signature",
71
+ SpendPermission: "signature",
72
+ PreApproval: "tx",
73
+ },
74
+ lifecycleStates: [
75
+ "proposed",
76
+ "authorized",
77
+ "captured",
78
+ "charged",
79
+ "voided",
80
+ "reclaimed",
81
+ "refunded",
82
+ ],
83
+ };