@metalntwx/metal-pay 0.2.0 → 0.3.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.
Files changed (70) hide show
  1. package/README.md +72 -33
  2. package/dist/clearing-abi.d.ts +1836 -0
  3. package/dist/clearing-abi.js +2361 -0
  4. package/dist/clearing-client.d.ts +49 -159
  5. package/dist/clearing-client.js +191 -564
  6. package/dist/clearing-codec.d.ts +57 -0
  7. package/dist/clearing-codec.js +173 -0
  8. package/dist/clearing-custody.d.ts +191 -0
  9. package/dist/clearing-custody.js +490 -0
  10. package/dist/clearing-server.d.ts +30 -49
  11. package/dist/clearing-server.js +87 -67
  12. package/dist/clearing-x402.d.ts +29 -0
  13. package/dist/clearing-x402.js +203 -0
  14. package/dist/client.d.ts +7 -13
  15. package/dist/client.js +16 -18
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +4 -1
  18. package/dist/mandates/binding.d.ts +4 -27
  19. package/dist/mandates/binding.js +5 -9
  20. package/dist/mandates/checkout.js +5 -5
  21. package/dist/mandates/client.js +4 -3
  22. package/dist/mandates/l3a.d.ts +2 -2
  23. package/dist/mandates/l3a.js +5 -4
  24. package/dist/mandates/seller.d.ts +1 -1
  25. package/dist/mandates/seller.js +1 -1
  26. package/dist/mandates/verify.d.ts +4 -6
  27. package/dist/mandates/verify.js +42 -66
  28. package/dist/mandates/vi/credential.js +4 -4
  29. package/dist/node.d.ts +11 -0
  30. package/dist/node.js +97 -0
  31. package/package.json +18 -11
  32. package/src/clearing-abi.ts +2361 -0
  33. package/src/clearing-client.ts +303 -0
  34. package/src/clearing-codec.test.ts +57 -0
  35. package/src/clearing-codec.ts +251 -0
  36. package/src/clearing-custody.test.ts +534 -0
  37. package/src/clearing-custody.ts +665 -0
  38. package/src/clearing-fixture.json +37 -0
  39. package/src/clearing-server.test.ts +388 -0
  40. package/src/clearing-server.ts +123 -0
  41. package/src/clearing-x402.test.ts +181 -0
  42. package/src/clearing-x402.ts +279 -0
  43. package/src/client.test.ts +325 -0
  44. package/src/client.ts +156 -0
  45. package/src/index.ts +33 -0
  46. package/src/mandates/account.ts +91 -0
  47. package/src/mandates/binding.ts +280 -0
  48. package/src/mandates/chain.ts +68 -0
  49. package/src/mandates/checkout.ts +368 -0
  50. package/src/mandates/client.ts +159 -0
  51. package/src/mandates/extension.ts +48 -0
  52. package/src/mandates/index.ts +121 -0
  53. package/src/mandates/ingest.ts +42 -0
  54. package/src/mandates/jcs.ts +16 -0
  55. package/src/mandates/l3a.ts +216 -0
  56. package/src/mandates/seller.ts +91 -0
  57. package/src/mandates/verify.ts +532 -0
  58. package/src/mandates/vi/credential.ts +477 -0
  59. package/src/mandates/vi/issue.ts +118 -0
  60. package/src/mandates/vi/sd-jwt.ts +189 -0
  61. package/src/networks.test.ts +16 -0
  62. package/src/networks.ts +152 -0
  63. package/src/node.test.ts +24 -0
  64. package/src/node.ts +96 -0
  65. package/src/server.test.ts +223 -0
  66. package/src/server.ts +77 -0
  67. package/dist/clearing.d.ts +0 -204
  68. package/dist/clearing.js +0 -151
  69. package/dist/file-storage.d.ts +0 -39
  70. package/dist/file-storage.js +0 -195
@@ -1,4 +1,4 @@
1
- // MetalAccount mandate ABI, typed agent keys, references, self-calls and direct pulls.
1
+ // MetalAccount mandate ABI, typed agent keys, references, self-calls and pulls.
2
2
  import { concat, encodeFunctionData, encodePacked, keccak256, parseAbi, size, slice } from "viem";
3
3
  /** Domain tag for the payment-only ERC-1271 path on MetalAccount. */
4
4
  export const MANDATE_SIGNATURE_TAG = "0x4d4e4454";
@@ -10,7 +10,6 @@ export const mandateBindingAbi = parseAbi([
10
10
  "struct Terms { string kid; string instrument; string currency; uint256 unitsPerMinor; Payee[] payees; bytes32 openSdHash; }",
11
11
  "struct PayeeTerms { bytes32 sdHash; string object; }",
12
12
  "struct MandateState { address principal; bool revoked; bytes32 agentX; bytes32 agentY; string kid; bytes32 openSdHash; uint64 boundBlock; uint64 occurrences; uint128 spent; }",
13
- "function mip20TransferHandler() pure returns (bytes4)",
14
13
  "function mandateReference(address principal, bytes agentKey, bytes32 hashC) pure returns (bytes32)",
15
14
  "function mandates(bytes32 ref) view returns (MandateState)",
16
15
  "function payee(bytes32 ref, address account) view returns (PayeeTerms)",
@@ -21,9 +20,9 @@ export const mandateBindingAbi = parseAbi([
21
20
  "function revoke(bytes32 ref)",
22
21
  "function isValidSignature(bytes32 hash, bytes blob) view returns (bytes4)",
23
22
  "function check(Pull p, L3a l) view returns (uint8)",
24
- "function pull(Pull p, L3a l)",
23
+ "function pull(Pull p, L3a l) returns (bool paid)",
25
24
  "event MandateBound(bytes32 indexed ref, bytes32 hashC, bytes32 issuerKey, bytes agentKey, Constraints constraints, Terms terms)",
26
- "event MandatePayment(bytes32 indexed ref, address indexed to, bytes20 l3aDigest, bytes agentKey, uint256 amount, uint256 spent, uint256 occurrences, bool direct)",
25
+ "event MandatePayment(bytes32 indexed ref, address indexed to, bytes20 l3aDigest, bytes agentKey, uint256 amount, uint256 spent, uint256 occurrences)",
27
26
  "event MandateRefused(bytes32 indexed ref, address indexed to, bytes20 l3aDigest, bytes agentKey, uint256 amount, uint8 reason)",
28
27
  "event MandateRevoked(bytes32 indexed ref)",
29
28
  "event MandateReplaced(bytes32 indexed predecessor, bytes32 indexed successor)",
@@ -44,9 +43,6 @@ export const mandateBindingAbi = parseAbi([
44
43
  "error NonceAlreadyUsed()",
45
44
  "error NotYetValid()",
46
45
  "error RefusalLimitReached()",
47
- "error NotApproved()",
48
- "error MandateBreach(uint8 reason)",
49
- "error UnauthorizedHookCaller()",
50
46
  ]);
51
47
  /** `MandateTypes.Reason` in declaration order, which is the order Mandate Binding checks them in; `None` is a pass. */
52
48
  export const REASONS = [
@@ -74,13 +70,13 @@ export const REFUSALS = {
74
70
  NoMandate: { reason: "mandate_not_bound", meaning: "no such mandate is bound on the account" },
75
71
  Revoked: { reason: "mandate_revoked", meaning: "the mandate is revoked, or replaced: the agent pays under the credential that replaced it" },
76
72
  Expired: { reason: "mandate_expired", meaning: "the mandate has expired" },
77
- BadSignature: { reason: "mandate_bad_signature", meaning: "the payment is not signed by the mandate's agent key" },
73
+ BadSignature: { reason: "mandate_bad_signature", meaning: "the payment is not signed by the mandate key" },
78
74
  NonceUsed: { reason: "mandate_nonce_used", meaning: "this payment was already made or refused" },
79
75
  OutsideWindow: { reason: "mandate_outside_window", meaning: "the payment's validity window is not open" },
80
76
  PayeeNotAllowed: { reason: "mandate_payee_not_listed", meaning: "the payee is not one the mandate allows" },
81
77
  OverPerPaymentCap: { reason: "mandate_cap_exceeded", meaning: "the amount is above the mandate's per-payment cap" },
82
78
  OverBudget: { reason: "mandate_budget_spent", meaning: "the amount would take the mandate past its budget" },
83
- OutsideRecurrence: { reason: "mandate_outside_recurrence", meaning: "today is outside the mandate's dates" },
79
+ OutsideRecurrence: { reason: "mandate_outside_recurrence", meaning: "the mandate has not started yet, or its dates have ended" },
84
80
  OverOccurrences: { reason: "mandate_occurrences_used", meaning: "the mandate's payment count is used up" },
85
81
  };
86
82
  /** `mandate_cap_exceeded (the amount is above the mandate's per-payment cap)`: a refusal as a person reads it. */
@@ -61,7 +61,7 @@ export function readCheckoutJwt(jwt) {
61
61
  const [h, p, sig] = segments;
62
62
  const header = JSON.parse(Buffer.from(fromB64url(h)).toString("utf8"));
63
63
  if (header.alg !== "ES256" || header.typ !== "JWT" || typeof header.kid !== "string")
64
- throw new Error("a checkout JWT is an ES256 JWT naming the agent key");
64
+ throw new Error("a checkout JWT is an ES256 JWT naming the mandate key");
65
65
  const offer = JSON.parse(Buffer.from(fromB64url(p)).toString("utf8"));
66
66
  if (offer === null || typeof offer !== "object" || Array.isArray(offer))
67
67
  throw new Error("a checkout JWT's payload is an object");
@@ -126,7 +126,7 @@ export function readL3b(serialized) {
126
126
  const payload = JSON.parse(Buffer.from(fromB64url(p)).toString("utf8"));
127
127
  const [, final] = JSON.parse(Buffer.from(fromB64url(checkoutDisclosure)).toString("utf8"));
128
128
  if (typeof header.kid !== "string")
129
- throw new Error("an L3b names the agent key");
129
+ throw new Error("an L3b names the mandate key");
130
130
  if (final.vct !== CHECKOUT_VCT || typeof final.checkout_jwt !== "string")
131
131
  throw new Error(`the disclosure is not a ${CHECKOUT_VCT} with a checkout JWT`);
132
132
  const signature = fromB64url(sig);
@@ -183,9 +183,9 @@ function checkCheckoutMandate(serialized, expect) {
183
183
  if (l3b.typ !== "kb-sd-jwt")
184
184
  problems.push("the checkout mandate's typ is not kb-sd-jwt");
185
185
  if (l3b.kid !== expect.kid)
186
- problems.push("the checkout mandate names another agent key");
186
+ problems.push("the checkout mandate names another mandate key");
187
187
  if (!verifyL3aSignature(expect.agent, sha256Hex(Buffer.from(l3b.signingInput, "ascii")), l3b.r, l3b.s))
188
- problems.push("the checkout mandate is not signed by the mandate's agent key");
188
+ problems.push("the checkout mandate is not signed by the mandate key");
189
189
  if (typeof l3b.aud !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(l3b.aud) || !isAddressEqual(l3b.aud, expect.payee))
190
190
  problems.push("the checkout mandate is for another payee");
191
191
  const now = expect.now ?? Math.floor(Date.now() / 1000);
@@ -215,7 +215,7 @@ function checkCheckoutMandate(serialized, expect) {
215
215
  let lineItemId = expect.lineItemId;
216
216
  if (jwt !== undefined) {
217
217
  if (jwt.kid !== expect.kid || !verifyL3aSignature(expect.agent, sha256Hex(Buffer.from(jwt.signingInput, "ascii")), jwt.r, jwt.s))
218
- problems.push("the checkout JWT is not signed by the mandate's agent key");
218
+ problems.push("the checkout JWT is not signed by the mandate key");
219
219
  if (expect.offer !== undefined && jwt.signingInput !== buildCheckoutJwt(expect.kid, expect.offer).signingInput)
220
220
  problems.push("the checkout JWT is not over the offer the payee served");
221
221
  const offer = expect.offer ?? jwt.offer;
@@ -5,9 +5,10 @@
5
5
  // `TransferWithAuthorization` whose `from` is the principal's account, whose
6
6
  // nonce is `bytes12(mandate reference) ‖ bytes20(SHA-256(L3a signing input))`,
7
7
  // whose window is the L3a's `iat` and `exp`, and whose signature is
8
- // `abi.encode(Pull, L3a fields)`, which the account's Mandate Binding accepts
9
- // or refuses through ERC-1271. Sellers and facilitators see an ordinary
10
- // `exact` payment; a seller that asks for the agent's signed order gets it in
8
+ // `MNDT || abi.encode(Pull, L3a fields)`, the arguments of `pull` on the
9
+ // principal's account. Sellers see an ordinary `exact` payment; Metal's
10
+ // facilitator submits it as that `pull`, where Mandate Binding accepts or
11
+ // refuses it. A seller that asks for the agent's signed order gets it in
11
12
  // the payload's `extensions`, and the per-payment mandate names its hash.
12
13
  //
13
14
  // `@x402/evm`'s `exact` client draws its own nonce and signs EIP-712 as
@@ -64,9 +64,9 @@ export declare function signL3a(args: {
64
64
  iss: string;
65
65
  signDigest: SignDigest;
66
66
  }): Promise<SignedL3a>;
67
- /** The relayed authorisation's signature: `MNDT || abi.encode(Pull, L3a fields)`. */
67
+ /** The x402 payload's signature: `MNDT || abi.encode(Pull, L3a fields)`, the arguments of `pull`. */
68
68
  export declare function encodeMandateBlob(pull: Pull, fields: L3aFields): Hex;
69
- /** The Pull and L3a fields a relayed authorisation's signature carries, as Mandate Binding's `check` and `pull` take them. */
69
+ /** The Pull and L3a fields an x402 payload's signature carries, as Mandate Binding's `check` and `pull` take them. */
70
70
  export declare function decodeMandateBlob(blob: Hex): {
71
71
  pull: Pull;
72
72
  fields: L3aFields;
@@ -4,8 +4,9 @@
4
4
  // verbatim), with `aud` the chain as `eip155:<chainId>`, `iat`/`exp` the
5
5
  // ERC-3009 window, and `transaction_id` the SHA-256 of the RFC 8785 form of
6
6
  // the seller's x402 payment requirements. The ERC-3009 nonce is
7
- // `bytes12(mandate reference) ‖ bytes20(SHA-256(signing input))`; the relayed
8
- // signature is `MNDT || abi.encode(Pull, L3a fields)`.
7
+ // `bytes12(mandate reference) ‖ bytes20(SHA-256(signing input))`; the x402
8
+ // payload's signature is `MNDT || abi.encode(Pull, L3a fields)`, which the
9
+ // facilitator decodes into `pull`'s arguments.
9
10
  import { p256 } from "@noble/curves/nist";
10
11
  import { bytesToHex, concat, decodeAbiParameters, encodeAbiParameters, getAbiItem, hexToBigInt, hexToBytes, isAddressEqual, numberToHex, size, slice, zeroAddress, } from "viem";
11
12
  import { mandateBindingAbi, referencePrefix } from "./binding.js";
@@ -145,11 +146,11 @@ export async function signL3a(args) {
145
146
  return { pull, fields: { ...unsigned, r, s }, serialized: serializeL3a(l3a, r, s), digest: l3a.digest };
146
147
  }
147
148
  const pullInputs = getAbiItem({ abi: mandateBindingAbi, name: "pull" }).inputs;
148
- /** The relayed authorisation's signature: `MNDT || abi.encode(Pull, L3a fields)`. */
149
+ /** The x402 payload's signature: `MNDT || abi.encode(Pull, L3a fields)`, the arguments of `pull`. */
149
150
  export function encodeMandateBlob(pull, fields) {
150
151
  return concat([MANDATE_SIGNATURE_TAG, encodeAbiParameters(pullInputs, [pull, fields])]);
151
152
  }
152
- /** The Pull and L3a fields a relayed authorisation's signature carries, as Mandate Binding's `check` and `pull` take them. */
153
+ /** The Pull and L3a fields an x402 payload's signature carries, as Mandate Binding's `check` and `pull` take them. */
153
154
  export function decodeMandateBlob(blob) {
154
155
  if (size(blob) < 4 || slice(blob, 0, 4) !== MANDATE_SIGNATURE_TAG)
155
156
  throw new Error("not a MetalAccount mandate signature");
@@ -17,7 +17,7 @@ export type RequireSignedOfferConfig = {
17
17
  * On a route whose offer asks for it (`extra.signedOffer: "required"`), a
18
18
  * payment under a mandate must carry the agent's signed order, checked before
19
19
  * the facilitator is asked: the payer's account binds the mandate the payment
20
- * names, and the signed order is that mandate's agent key over the offer the
20
+ * names, and the signed order is that mandate's key over the offer the
21
21
  * server served, for this payee, buying its one line item (`extra.resource`
22
22
  * when the route names one, else the payee). A refusal is a 402 whose reason
23
23
  * names the check that failed, a payment that names no mandate included. A
@@ -13,7 +13,7 @@ import { asksSignedOffer, mandateRefOf, signedOfferOf } from "./extension.js";
13
13
  * On a route whose offer asks for it (`extra.signedOffer: "required"`), a
14
14
  * payment under a mandate must carry the agent's signed order, checked before
15
15
  * the facilitator is asked: the payer's account binds the mandate the payment
16
- * names, and the signed order is that mandate's agent key over the offer the
16
+ * names, and the signed order is that mandate's key over the offer the
17
17
  * server served, for this payee, buying its one line item (`extra.resource`
18
18
  * when the route names one, else the payee). A refusal is a 402 whose reason
19
19
  * names the check that failed, a payment that names no mandate included. A
@@ -14,10 +14,10 @@ export interface VerifyStep {
14
14
  detail: string;
15
15
  }
16
16
  export declare const STEP_TITLES: {
17
- readonly 1: "The mandate is bound on the principal's account: hash(C), the limits, the payees, the agent key and the issuer key are on chain";
18
- readonly 2: "The credential in hand hashes to the bound hash(C) and states the bound limits, payees and agent key";
17
+ readonly 1: "The mandate is bound on the principal's account: hash(C), the limits, the payees, the mandate key and the issuer key are on chain";
18
+ readonly 2: "The credential in hand hashes to the bound hash(C) and states the bound limits, payees and mandate key";
19
19
  readonly 3: "The credential's signatures: the issuer's under a trusted key that is the one recorded at binding, the owner's under the key the issuer bound";
20
- readonly 4: "Each payment and refusal is the agent key's per-payment mandate, rebuilt from its transaction, inside the mandate at its block";
20
+ readonly 4: "Each payment and refusal is the mandate key's per-payment mandate, rebuilt from its transaction, inside the mandate at its block";
21
21
  };
22
22
  /** A mandate as its `MandateBound` event records it. */
23
23
  export interface BoundMandate {
@@ -40,11 +40,9 @@ export interface VerifiedPayment {
40
40
  kind: "payment" | "refusal";
41
41
  txHash: Hash;
42
42
  block: bigint;
43
- /** Whether `pull` submitted it, with no seller; otherwise a relayer submitted the ERC-3009 authorisation to the token. */
44
- direct: boolean;
45
43
  to: Address;
46
44
  amount: bigint;
47
- /** The ERC-3009 nonce the token recorded; a seller's `/settlements` row carries it for a relayed payment. */
45
+ /** The ERC-3009 nonce the token recorded; a seller's `/settlements` row carries it for a payment the facilitator settled. */
48
46
  nonce?: Hex;
49
47
  reason?: ReasonName;
50
48
  /** The L3a SD-JWT, as the VI reference serialises it, when the transaction carried one. */
@@ -8,14 +8,14 @@
8
8
  // changes, so what it is now it was when it bound and paid. What a verifier
9
9
  // cannot rebuild (the budget's running total, the nonce's single use) is read
10
10
  // as the contract recorded it in the event and checked against the limits.
11
- import { decodeFunctionData, formatLog, getAbiItem, hexToBigInt, isAddressEqual, numberToHex, parseAbi, erc20Abi, parseEventLogs, slice, toFunctionSelector, } from "viem";
11
+ import { decodeFunctionData, formatLog, getAbiItem, hexToBigInt, isAddressEqual, numberToHex, erc20Abi, parseEventLogs, slice, toFunctionSelector, } from "viem";
12
12
  import { REASONS, REFUSALS, decodeAgentKey, encodeAgentKey, mandateBindingAbi, mandateReference, reasonName, sameAgentKey, } from "./binding.js";
13
13
  import { accountFactoryFor, metalNetwork } from "../networks.js";
14
14
  import { assertMetalAccount, metalAccountCode, metalAccountFactoryAbi } from "./account.js";
15
15
  import { publicMessage } from "./chain.js";
16
16
  import { verifyCheckoutMandate, x402Offer, lineItemOf } from "./checkout.js";
17
17
  import { jcs } from "./jcs.js";
18
- import { buildL3a, decodeMandateBlob, mandateNonce, serializeL3a, transactionIdOf, verifyL3aSignature } from "./l3a.js";
18
+ import { buildL3a, mandateNonce, serializeL3a, transactionIdOf, verifyL3aSignature } from "./l3a.js";
19
19
  import { extractTerms, hashCredential, issuerKeyId, verifyCredential } from "./vi/credential.js";
20
20
  function before(a, b) {
21
21
  return a.block < b.block || (a.block === b.block && (a.transactionIndex < b.transactionIndex || (a.transactionIndex === b.transactionIndex && a.logIndex < b.logIndex)));
@@ -26,17 +26,11 @@ function position(log) {
26
26
  return { block: log.blockNumber, transactionIndex: log.transactionIndex, logIndex: log.logIndex };
27
27
  }
28
28
  export const STEP_TITLES = {
29
- 1: "The mandate is bound on the principal's account: hash(C), the limits, the payees, the agent key and the issuer key are on chain",
30
- 2: "The credential in hand hashes to the bound hash(C) and states the bound limits, payees and agent key",
29
+ 1: "The mandate is bound on the principal's account: hash(C), the limits, the payees, the mandate key and the issuer key are on chain",
30
+ 2: "The credential in hand hashes to the bound hash(C) and states the bound limits, payees and mandate key",
31
31
  3: "The credential's signatures: the issuer's under a trusted key that is the one recorded at binding, the owner's under the key the issuer bound",
32
- 4: "Each payment and refusal is the agent key's per-payment mandate, rebuilt from its transaction, inside the mandate at its block",
32
+ 4: "Each payment and refusal is the mandate key's per-payment mandate, rebuilt from its transaction, inside the mandate at its block",
33
33
  };
34
- const erc3009Abi = parseAbi([
35
- "function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, bytes signature)",
36
- "function transferWithAuthorizationWithPolicy(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, bytes signature, bytes policyData)",
37
- "function receiveWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, bytes signature)",
38
- "function receiveWithAuthorizationWithPolicy(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, bytes signature, bytes policyData)",
39
- ]);
40
34
  const short = (hex) => `${hex.slice(0, 10)}…`;
41
35
  /** A reason as the facilitator names it to a payer. */
42
36
  const named = (reason) => (reason === "None" ? "none" : REFUSALS[reason].reason);
@@ -77,45 +71,30 @@ function reasonContradiction(reason, pull, m, at) {
77
71
  return `it breaks the mandate: ${named(first)}`;
78
72
  return REASONS.indexOf(first) < REASONS.indexOf(reason) ? `${named(first)} holds, and Mandate Binding checks it before ${named(reason)}` : undefined;
79
73
  }
80
- /** The selectors a payment's calldata carries: `pull` on the principal, or an ERC-3009 authorisation on the token. */
74
+ /** The selector a payment's calldata carries: `pull` on the principal, whoever submitted it. */
81
75
  const PULL_SELECTOR = toFunctionSelector(getAbiItem({ abi: mandateBindingAbi, name: "pull" }));
82
- const ERC3009_SELECTORS = erc3009Abi.map((item) => toFunctionSelector(item));
83
76
  /**
84
- * Every pull of the principal's and every ERC-3009 authorisation from it that
85
- * `input` carries: at the top level, or inside whatever wrapped the call (a
86
- * multicall, a sponsor's batch, the quorum's own execute). Each selector in
87
- * the calldata is tried as a call's start; the one the event's digest names
88
- * is the payment's, and a forged candidate rebuilds to another digest.
77
+ * Every pull that `input` carries: at the top level, or inside whatever
78
+ * wrapped the call (a multicall, a sponsor's batch, the quorum's own
79
+ * execute). Each occurrence of the selector in the calldata is tried as a
80
+ * call's start; the one the event's digest names is the payment's, and a
81
+ * forged candidate rebuilds to another digest.
89
82
  */
90
- function carriedL3as(input, m) {
83
+ function carriedL3as(input) {
91
84
  const found = [];
92
85
  const body = input.slice(2);
93
- for (const selector of [PULL_SELECTOR, ...ERC3009_SELECTORS]) {
94
- for (let at = body.indexOf(selector.slice(2)); at !== -1; at = body.indexOf(selector.slice(2), at + 2)) {
95
- if (at % 2 !== 0)
86
+ for (let at = body.indexOf(PULL_SELECTOR.slice(2)); at !== -1; at = body.indexOf(PULL_SELECTOR.slice(2), at + 2)) {
87
+ if (at % 2 !== 0)
88
+ continue;
89
+ try {
90
+ const call = decodeFunctionData({ abi: mandateBindingAbi, data: `0x${body.slice(at)}` });
91
+ if (call.functionName !== "pull")
96
92
  continue;
97
- const data = `0x${body.slice(at)}`;
98
- try {
99
- if (selector === PULL_SELECTOR) {
100
- const call = decodeFunctionData({ abi: mandateBindingAbi, data });
101
- if (call.functionName !== "pull")
102
- continue;
103
- const [pull, fields] = call.args;
104
- found.push({ pull: { ...pull }, fields: { ...fields }, direct: true });
105
- }
106
- else {
107
- const [from, payee, value, validAfter, validBefore, nonce, signature] = decodeFunctionData({ abi: erc3009Abi, data }).args;
108
- if (!isAddressEqual(from, m.principal))
109
- continue;
110
- const { pull, fields } = decodeMandateBlob(signature);
111
- const same = isAddressEqual(pull.to, payee) && pull.value === value && pull.validAfter === validAfter && pull.validBefore === validBefore && pull.nonce.toLowerCase() === nonce.toLowerCase();
112
- if (same)
113
- found.push({ pull, fields, direct: false });
114
- }
115
- }
116
- catch {
117
- // Not a call that starts here.
118
- }
93
+ const [pull, fields] = call.args;
94
+ found.push({ pull: { ...pull }, fields: { ...fields } });
95
+ }
96
+ catch {
97
+ // Not a call that starts here.
119
98
  }
120
99
  }
121
100
  return found;
@@ -124,11 +103,10 @@ function carriedL3as(input, m) {
124
103
  * The candidate that is the outcome the event records: it rebuilds to the
125
104
  * recorded digest, moves the mandate's token, and its nonce commits to the
126
105
  * mandate and that digest (the L3a hashes neither the token nor the nonce,
127
- * so a copy with either changed is not it); among those, one carried the way
128
- * the event says (a pull, or a relayed authorisation) and signed by the agent
129
- * key comes first, so unrelated calldata with the same L3a (a no-op pull to
130
- * another address, a copy under another signature) cannot stand in for the
131
- * payment. One another mandate's terms cannot rebuild is not a candidate.
106
+ * so a copy with either changed is not it); among those, one signed by the
107
+ * agent key comes first, so unrelated calldata with the same L3a (a copy
108
+ * under another signature) cannot stand in for the payment. One another
109
+ * mandate's terms cannot rebuild is not a candidate.
132
110
  */
133
111
  function carriedL3a(input, chainId, m, o) {
134
112
  const rank = (c) => {
@@ -138,13 +116,13 @@ function carriedL3a(input, chainId, m, o) {
138
116
  return 0;
139
117
  if (!isAddressEqual(c.pull.token, m.constraints.token) || mandateNonce(m.ref, l3a.digest) !== c.pull.nonce.toLowerCase())
140
118
  return 0;
141
- return 1 + (c.direct === o.direct ? 2 : 0) + (verifyL3aSignature(m.agentKey, l3a.digest, c.fields.r, c.fields.s) ? 1 : 0);
119
+ return 1 + (verifyL3aSignature(m.agentKey, l3a.digest, c.fields.r, c.fields.s) ? 1 : 0);
142
120
  }
143
121
  catch {
144
122
  return 0;
145
123
  }
146
124
  };
147
- const candidates = carriedL3as(input, m);
125
+ const candidates = carriedL3as(input);
148
126
  let carried;
149
127
  let best = 0;
150
128
  for (const c of candidates) {
@@ -153,7 +131,7 @@ function carriedL3a(input, chainId, m, o) {
153
131
  [carried, best] = [c, r];
154
132
  }
155
133
  if (carried === undefined)
156
- throw new Error(candidates.length === 0 ? "the transaction carries no pull on the principal and no ERC-3009 authorisation from it" : "no call the transaction carries rebuilds to the per-payment mandate the event records");
134
+ throw new Error(candidates.length === 0 ? "the transaction carries no pull on the principal" : "no pull the transaction carries rebuilds to the per-payment mandate the event records");
157
135
  return carried;
158
136
  }
159
137
  /**
@@ -208,7 +186,7 @@ async function boundMandate(client, principal, ref, factory, toBlock) {
208
186
  const agentKey = decodeAgentKey(log.args.agentKey);
209
187
  const problems = [];
210
188
  if (mandateReference(principal, agentKey, log.args.hashC).toLowerCase() !== ref.toLowerCase())
211
- problems.push("the reference does not derive from the principal, the agent key and hash(C)");
189
+ problems.push("the reference does not derive from the principal, the mandate key and hash(C)");
212
190
  const revoked = events.find((e) => e.eventName === "MandateRevoked");
213
191
  const replaced = events.find((e) => e.eventName === "MandateReplaced");
214
192
  const revokedBlock = revoked?.blockNumber ?? undefined;
@@ -238,28 +216,26 @@ function outcomesOf(events, only) {
238
216
  if (log.blockNumber === null || log.transactionHash === null)
239
217
  continue;
240
218
  if (log.eventName === "MandatePayment") {
241
- const { to, amount, l3aDigest, agentKey, direct, spent, occurrences } = log.args;
242
- outcomes.push({ ...position(log), kind: "payment", txHash: log.transactionHash, block: log.blockNumber, to, amount, l3aDigest, agentKey, direct, spent, occurrences });
219
+ const { to, amount, l3aDigest, agentKey, spent, occurrences } = log.args;
220
+ outcomes.push({ ...position(log), kind: "payment", txHash: log.transactionHash, block: log.blockNumber, to, amount, l3aDigest, agentKey, spent, occurrences });
243
221
  }
244
222
  else if (log.eventName === "MandateRefused") {
245
223
  const { to, amount, l3aDigest, agentKey, reason } = log.args;
246
- outcomes.push({ ...position(log), kind: "refusal", txHash: log.transactionHash, block: log.blockNumber, to, amount, l3aDigest, agentKey, direct: true, reason: reasonName(reason) });
224
+ outcomes.push({ ...position(log), kind: "refusal", txHash: log.transactionHash, block: log.blockNumber, to, amount, l3aDigest, agentKey, reason: reasonName(reason) });
247
225
  }
248
226
  }
249
227
  outcomes.sort((a, b) => before(a, b) ? -1 : before(b, a) ? 1 : 0);
250
228
  return only === undefined ? outcomes : outcomes.filter((o) => o.txHash.toLowerCase() === only.toLowerCase());
251
229
  }
252
230
  async function verifyOutcome(client, chainId, m, o, one) {
253
- const verified = { kind: o.kind, txHash: o.txHash, block: o.block, direct: o.direct, to: o.to, amount: o.amount, ...(o.reason === undefined ? {} : { reason: o.reason }), problems: [] };
231
+ const verified = { kind: o.kind, txHash: o.txHash, block: o.block, to: o.to, amount: o.amount, ...(o.reason === undefined ? {} : { reason: o.reason }), problems: [] };
254
232
  const found = verified.problems;
255
233
  const agentKey = decodeAgentKey(o.agentKey);
256
234
  if (!sameAgentKey(agentKey, m.agentKey))
257
- found.push("not the bound agent key");
235
+ found.push("not the bound mandate key");
258
236
  try {
259
237
  const [tx, block] = await Promise.all([client.getTransaction({ hash: o.txHash }), client.getBlock({ blockNumber: o.block })]);
260
- const { pull, fields, direct } = carriedL3a(tx.input, chainId, m, o);
261
- if (direct !== o.direct)
262
- found.push(direct ? "recorded as relayed, carried as a pull" : "recorded as a pull, carried as a relayed authorisation");
238
+ const { pull, fields } = carriedL3a(tx.input, chainId, m, o);
263
239
  if (!isAddressEqual(pull.token, m.constraints.token))
264
240
  found.push("another token than the mandate's");
265
241
  const receipt = await client.getTransactionReceipt({ hash: o.txHash });
@@ -278,7 +254,7 @@ async function verifyOutcome(client, chainId, m, o, one) {
278
254
  if (mandateNonce(m.ref, l3a.digest) !== pull.nonce.toLowerCase())
279
255
  found.push("its nonce does not commit to the mandate and the per-payment mandate");
280
256
  if (!verifyL3aSignature(m.agentKey, l3a.digest, fields.r, fields.s))
281
- found.push("not the bound agent key's signature");
257
+ found.push("not the bound mandate key's signature");
282
258
  if (!isAddressEqual(pull.to, o.to))
283
259
  found.push("another payee than the event records");
284
260
  if (pull.value !== o.amount)
@@ -341,7 +317,7 @@ async function verify(client, found, opts, only) {
341
317
  skip(4, "no binding to check against");
342
318
  return { ok: false, steps, payments: [] };
343
319
  }
344
- record(1, found.problems, `${short(m.ref)} bound in block ${m.block} by ${m.principal}: hash(C) ${m.hashC}, issuer key ${m.issuerKey}, agent key ${short(encodeAgentKey(m.agentKey))}, ${m.terms.payees.length} payee(s)` +
320
+ record(1, found.problems, `${short(m.ref)} bound in block ${m.block} by ${m.principal}: hash(C) ${m.hashC}, issuer key ${m.issuerKey}, mandate key ${short(encodeAgentKey(m.agentKey))}, ${m.terms.payees.length} payee(s)` +
345
321
  `${m.revokedBlock === undefined ? "" : `, revoked in block ${m.revokedBlock}`}${m.successor === undefined ? "" : `, replaced by ${short(m.successor)}`}`);
346
322
  const c = opts.credential;
347
323
  let credentialIsBound = false;
@@ -357,7 +333,7 @@ async function verify(client, found, opts, only) {
357
333
  const stated = extractTerms(c);
358
334
  const differs = [
359
335
  ["principal", isAddressEqual(stated.principal, m.principal)],
360
- ["agent key", sameAgentKey(stated.agentKey, m.agentKey)],
336
+ ["mandate key", sameAgentKey(stated.agentKey, m.agentKey)],
361
337
  ["limits", jcs(toJson(stated.constraints)) === jcs(toJson(m.constraints))],
362
338
  ["payees", jcs(toJson(stated.terms)) === jcs(toJson(m.terms))],
363
339
  ].filter(([, same]) => !same);
@@ -369,7 +345,7 @@ async function verify(client, found, opts, only) {
369
345
  }
370
346
  }
371
347
  credentialIsBound = problems.length === 0;
372
- record(2, problems, "the credential hashes to the bound hash(C), and binds exactly the limits, payees and agent key it states");
348
+ record(2, problems, "the credential hashes to the bound hash(C), and binds exactly the limits, payees and mandate key it states");
373
349
  }
374
350
  if (c === undefined || !credentialIsBound)
375
351
  skip(3, c === undefined ? "no credential in hand" : "the credential in hand is not the bound one");
@@ -401,7 +377,7 @@ async function verify(client, found, opts, only) {
401
377
  if ((opts.requirements !== undefined || opts.checkoutMandate !== undefined) && outcomes.length > 1)
402
378
  problems.push("requirements or a signed offer given, but more than one payment to check them against");
403
379
  const paid = payments.filter((p) => p.kind === "payment").length;
404
- record(4, problems, `${paid} payment(s) and ${payments.length - paid} refusal(s), each the bound agent key's per-payment mandate rebuilt byte for byte from its transaction, inside the mandate at its block`);
380
+ record(4, problems, `${paid} payment(s) and ${payments.length - paid} refusal(s), each the bound mandate key's per-payment mandate rebuilt byte for byte from its transaction, inside the mandate at its block`);
405
381
  return { ok: steps.every((s) => s.outcome !== "fail"), steps, mandate: m, payments };
406
382
  }
407
383
  /** The factory the verifier trusts on `client`'s chain: the caller's, else the one the network table names. */
@@ -273,13 +273,13 @@ function resolveTerms({ l1, l2 }, c) {
273
273
  const paymentJwk = obj(obj(paymentMandate.cnf, "payment cnf").jwk, "payment cnf.jwk");
274
274
  const agentJwk = { ...p256Jwk(paymentJwk, "payment cnf.jwk", "MalformedMandate"), kid: str(paymentJwk, "kid") };
275
275
  if (agentJwk.kid !== jwkThumbprint(agentJwk))
276
- fail("MalformedMandate", "the agent key's kid is not its RFC 7638 thumbprint");
276
+ fail("MalformedMandate", "the mandate key's kid is not its RFC 7638 thumbprint");
277
277
  if (jcs(checkoutJwk) !== jcs(paymentJwk))
278
- fail("MalformedMandate", "the checkout and payment mandates name different agent keys");
278
+ fail("MalformedMandate", "the checkout and payment mandates name different mandate keys");
279
279
  if (l2.payload.aud !== agentJwk.kid)
280
- fail("MalformedMandate", "layer2 aud is not the agent key's kid");
280
+ fail("MalformedMandate", "layer2 aud is not the mandate key's kid");
281
281
  if (/["\\]|[^\x20-\x7e]/.test(agentJwk.kid))
282
- fail("MalformedMandate", "the agent key's kid is not printable ASCII");
282
+ fail("MalformedMandate", "the mandate key's kid is not printable ASCII");
283
283
  const instrumentObject = obj(paymentMandate.payment_instrument, "payment_instrument");
284
284
  if (instrumentObject.type !== INSTRUMENT_TYPE)
285
285
  fail("MalformedMandate", `payment_instrument.type must be ${INSTRUMENT_TYPE}`);
package/dist/node.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { ClearingClientStore } from "./clearing-client.js";
2
+ import type { Hex } from "viem";
3
+ /** A persistent P-256 account and receipt journal for a single server process. */
4
+ export declare function openClearingWallet(directory: string): Promise<{
5
+ signer: {
6
+ publicKey: `0x${string}`;
7
+ sign(namespace: string, message: Hex): Hex;
8
+ };
9
+ store: ClearingClientStore;
10
+ close(): Promise<void>;
11
+ }>;
package/dist/node.js ADDED
@@ -0,0 +1,97 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { mkdir, open, readFile, rename } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ import { createClearingSigner } from "./clearing-codec.js";
6
+ /** A persistent P-256 account and receipt journal for a single server process. */
7
+ export async function openClearingWallet(directory) {
8
+ await mkdir(directory, { recursive: true, mode: 0o700 });
9
+ // SQLite holds an OS lock for the wallet lifetime and releases it on crash.
10
+ const owner = new DatabaseSync(join(directory, "owner.sqlite"));
11
+ try {
12
+ owner.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner (id INTEGER)");
13
+ }
14
+ catch (error) {
15
+ owner.close();
16
+ throw new Error("clearing wallet is already in use", { cause: error });
17
+ }
18
+ try {
19
+ const keyPath = join(directory, "account.key");
20
+ let key;
21
+ try {
22
+ key = (await readFile(keyPath, "utf8")).trim();
23
+ }
24
+ catch (error) {
25
+ if (error.code !== "ENOENT")
26
+ throw error;
27
+ for (;;) {
28
+ key = `0x${randomBytes(32).toString("hex")}`;
29
+ try {
30
+ createClearingSigner(key);
31
+ break;
32
+ }
33
+ catch {
34
+ /* Rejection sample the scalar. */
35
+ }
36
+ }
37
+ const file = await open(keyPath, "wx", 0o600);
38
+ try {
39
+ await file.writeFile(key);
40
+ await file.sync();
41
+ }
42
+ finally {
43
+ await file.close();
44
+ }
45
+ }
46
+ const parent = await open(directory, "r");
47
+ try {
48
+ await parent.sync();
49
+ }
50
+ finally {
51
+ await parent.close();
52
+ }
53
+ const signer = createClearingSigner(key);
54
+ const statePath = join(directory, "payments.json");
55
+ const store = {
56
+ async load() {
57
+ try {
58
+ return JSON.parse(await readFile(statePath, "utf8"));
59
+ }
60
+ catch (error) {
61
+ if (error.code === "ENOENT")
62
+ return null;
63
+ throw error;
64
+ }
65
+ },
66
+ async save(state) {
67
+ const file = await open(statePath + ".next", "w", 0o600);
68
+ try {
69
+ await file.writeFile(JSON.stringify(state));
70
+ await file.sync();
71
+ }
72
+ finally {
73
+ await file.close();
74
+ }
75
+ await rename(statePath + ".next", statePath);
76
+ const parent = await open(directory, "r");
77
+ try {
78
+ await parent.sync();
79
+ }
80
+ finally {
81
+ await parent.close();
82
+ }
83
+ },
84
+ };
85
+ return {
86
+ signer,
87
+ store,
88
+ async close() {
89
+ owner.close();
90
+ },
91
+ };
92
+ }
93
+ catch (error) {
94
+ owner.close();
95
+ throw error;
96
+ }
97
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@metalntwx/metal-pay",
3
- "version": "0.2.0",
4
- "description": "Agent payments on Metal for the official x402 packages: the Metal devnet and testnet as `exact`-scheme networks priced in `*USDZ` for payers and sellers, a one-call payment client with a spending limit, Metal's seller token, the clearing lane for payers who pay per call without a transaction each time, and mandates, which let an agent pay from its owner's account within limits Metal enforces.",
3
+ "version": "0.3.0",
4
+ "description": "Agent payments on Metal for the official x402 packages: native Bajillion clearing, on-chain exact payments, durable payer wallets, seller integration, and mandates, which let an agent pay from its owner's account within limits Metal enforces.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -9,6 +9,9 @@
9
9
  "directory": "sdks/metal-pay"
10
10
  },
11
11
  "type": "module",
12
+ "engines": {
13
+ "node": "^22.13.0 || >=23.4.0"
14
+ },
12
15
  "main": "./dist/index.js",
13
16
  "types": "./dist/index.d.ts",
14
17
  "exports": {
@@ -16,9 +19,9 @@
16
19
  "types": "./dist/index.d.ts",
17
20
  "default": "./dist/index.js"
18
21
  },
19
- "./file-storage": {
20
- "types": "./dist/file-storage.d.ts",
21
- "default": "./dist/file-storage.js"
22
+ "./node": {
23
+ "types": "./dist/node.d.ts",
24
+ "default": "./dist/node.js"
22
25
  },
23
26
  "./mandates": {
24
27
  "types": "./dist/mandates/index.d.ts",
@@ -26,21 +29,25 @@
26
29
  }
27
30
  },
28
31
  "files": [
29
- "dist"
32
+ "dist",
33
+ "src"
30
34
  ],
31
35
  "dependencies": {
32
- "@noble/curves": "^1.9.1",
33
- "@x402/core": "^2.21.0",
34
- "@x402/evm": "^2.21.0",
35
- "@x402/fetch": "^2.21.0"
36
+ "@noble/curves": "1.9.7"
36
37
  },
37
38
  "peerDependencies": {
39
+ "@x402/core": ">=2.28.0 <3",
40
+ "@x402/evm": ">=2.28.0 <3",
41
+ "@x402/fetch": ">=2.28.0 <3",
38
42
  "viem": "^2"
39
43
  },
40
44
  "devDependencies": {
41
45
  "@metalntwx/metal-ts": "0.3.2",
42
46
  "@types/node": "^24.13.2",
43
- "@x402/hono": "2.21.0",
47
+ "@x402/core": "2.28.0",
48
+ "@x402/evm": "2.28.0",
49
+ "@x402/fetch": "2.28.0",
50
+ "@x402/hono": "2.28.0",
44
51
  "hono": "4.13.7",
45
52
  "tsx": "^4.23.0",
46
53
  "typescript": "~6.0.2",