@metalntwx/metal-pay 0.3.2 → 0.3.3

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.
@@ -81,6 +81,15 @@ export function createClearingClient(config) {
81
81
  const chain = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
82
82
  let tail = Promise.resolve();
83
83
  let broken = false;
84
+ // The contract writes its clearing unit once, so one successful read serves every payment.
85
+ let unitRead;
86
+ function unit() {
87
+ unitRead ??= chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "unit" }).catch((error) => {
88
+ unitRead = undefined;
89
+ throw error;
90
+ });
91
+ return unitRead;
92
+ }
84
93
  async function save(state) {
85
94
  try {
86
95
  await config.store.save(state);
@@ -173,15 +182,11 @@ export function createClearingClient(config) {
173
182
  if (BigInt(context.epoch) !== epoch)
174
183
  return betweenEpochs(cause);
175
184
  opened = true;
176
- const [{ amount }, unit] = await Promise.all([
185
+ const [{ amount }, units] = await Promise.all([
177
186
  request(`/spendable/${config.signer.publicKey}`),
178
- chain.readContract({
179
- address: config.settlement,
180
- abi: clearingAbi,
181
- functionName: "unit",
182
- }),
187
+ unit(),
183
188
  ]);
184
- covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount), unit);
189
+ covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount), units);
185
190
  }
186
191
  catch (error) {
187
192
  if (!opened && unavailable(error))
@@ -271,7 +276,7 @@ export function createClearingClient(config) {
271
276
  context.asset.toLowerCase() !== config.asset.toLowerCase())
272
277
  throw new Error("unsupported clearing service deployment");
273
278
  const epoch = BigInt(context.epoch);
274
- const [registered, onchain, fault, admitted, block, unit] = await onChain(Promise.all([
279
+ const [registered, onchain, fault, admitted, block, units] = await onChain(Promise.all([
275
280
  chain.readContract({
276
281
  address: config.settlement,
277
282
  abi: clearingAbi,
@@ -294,11 +299,7 @@ export function createClearingClient(config) {
294
299
  functionName: "admittedCount",
295
300
  }),
296
301
  chain.getBlock(),
297
- chain.readContract({
298
- address: config.settlement,
299
- abi: clearingAbi,
300
- functionName: "unit",
301
- }),
302
+ unit(),
302
303
  ]));
303
304
  if (fault)
304
305
  throw faulted();
@@ -320,7 +321,7 @@ export function createClearingClient(config) {
320
321
  entries.push(entry);
321
322
  }
322
323
  // The invoice is in token base units; the vector counts clearing units.
323
- entry.cumulative = (BigInt(entry.cumulative) + clearingUnits(amount, unit)).toString();
324
+ entry.cumulative = (BigInt(entry.cumulative) + clearingUnits(amount, units)).toString();
324
325
  entry.count = (BigInt(entry.count) + 1n).toString();
325
326
  // The service takes at most this many recipients in one payment's vector.
326
327
  if (entries.length > MAX_ENTRIES)
@@ -13,6 +13,7 @@ export const ACCOUNT_BATCH_MODE = "0x0100000000000000000000000000000000000000000
13
13
  export function metalAccountCode(implementation) {
14
14
  return concat(["0x363d3d373d3d3d363d73", implementation, "0x5af43d82803e903d91602b57fd5bf3"]).toLowerCase();
15
15
  }
16
+ const EARLIER_FACTORY = "such as an earlier one";
16
17
  /** The factory is a caller-selected trust anchor, never inferred from payer-controlled data. */
17
18
  export async function assertMetalAccount(client, account, factory, blockNumber) {
18
19
  const at = blockNumber === undefined ? {} : { blockNumber };
@@ -21,8 +22,11 @@ export async function assertMetalAccount(client, account, factory, blockNumber)
21
22
  const when = blockNumber === undefined ? "" : ` at block ${blockNumber}`;
22
23
  if (code === undefined || code === "0x")
23
24
  throw new Error(`${account} has no code${when}: no account is deployed there`);
24
- if (code.toLowerCase() !== metalAccountCode(implementation))
25
- throw new Error(`${account} is not a MetalAccount clone of the trusted factory ${factory}`);
25
+ if (code.toLowerCase() === metalAccountCode(implementation))
26
+ return;
27
+ if (/^0x363d3d373d3d3d363d73[0-9a-f]{40}5af43d82803e903d91602b57fd5bf3$/.test(code.toLowerCase()))
28
+ throw new Error(`${account} was made by another factory than ${factory}, ${EARLIER_FACTORY}`);
29
+ throw new Error(`${account} is not a MetalAccount clone of the trusted factory ${factory}`);
26
30
  }
27
31
  /** The account's own execution, with a quorum of keys. */
28
32
  const EXECUTION_GAS = 500000n;
@@ -72,7 +76,10 @@ export async function authorizeMandateCall(client, factory, call, validUntil, si
72
76
  const account = first.to;
73
77
  if (calls.some((c) => !isAddressEqual(c.to, account)))
74
78
  throw new Error("every call in one batch goes to the same account");
75
- await assertMetalAccount(client, account, factory);
79
+ await assertMetalAccount(client, account, factory).catch((error) => {
80
+ // Only the owner can act on it, so only the owner's path says what to do.
81
+ throw error.message.endsWith(EARLIER_FACTORY) ? new Error(`${error.message}: create a new account with this version`, { cause: error }) : error;
82
+ });
76
83
  // A fresh nonce lane per call: two administration calls in flight, or one
77
84
  // beside the owner's own operations on lane 0, never claim one nonce. A
78
85
  // quorum's lane keeps the key's top bit clear; set, it is a lane for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalntwx/metal-pay",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
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": {
@@ -48,7 +48,7 @@
48
48
  "@x402/evm": "2.28.0",
49
49
  "@x402/fetch": "2.28.0",
50
50
  "@x402/hono": "2.28.0",
51
- "hono": "4.13.7",
51
+ "hono": "4.13.13",
52
52
  "tsx": "^4.23.0",
53
53
  "typescript": "~6.0.2",
54
54
  "viem": "^2",
@@ -29,11 +29,12 @@ const OTHER = "http://seller.test/chunk/2";
29
29
  /** The Clearing contract's epoch count, the service's epoch 1 admitted, its fault, none, the chain's id, and whether the contract is there, unless set otherwise. */
30
30
  const chain = { admitted: 1n, fault: 0, id: 1337, deployed: true };
31
31
  const head = { root: ZERO32, operations: 0n, floor: 0n };
32
+ let unitReads = 0;
32
33
  const views: Record<string, () => unknown> = {
33
34
  registered: () => true,
34
35
  fault: () => chain.fault,
35
36
  admittedCount: () => chain.admitted,
36
- unit: () => UNIT,
37
+ unit: () => (unitReads++, UNIT),
37
38
  operatorKey: () => operator.publicKey,
38
39
  contextOf: () => ({
39
40
  anchor: ANCHOR,
@@ -689,6 +690,29 @@ describe("clearing refusals", () => {
689
690
  });
690
691
  });
691
692
 
693
+ describe("chain reads", () => {
694
+ it("reads the contract's clearing unit once, since the contract writes it once", async () => {
695
+ const before = unitReads;
696
+ const { client, posted } = wallet();
697
+ await client.pay(invoiceFor(CHUNK), CHUNK);
698
+ await client.pay(invoiceFor(OTHER), OTHER);
699
+ expect(posted).toHaveLength(2);
700
+ expect(unitReads - before).toBe(1);
701
+ });
702
+
703
+ it("reads the unit again after a read that failed", async () => {
704
+ const { client, posted } = wallet();
705
+ chain.deployed = false;
706
+ try {
707
+ await expect(client.pay(invoiceFor(CHUNK), CHUNK)).rejects.toThrow();
708
+ } finally {
709
+ chain.deployed = true;
710
+ }
711
+ await client.pay(invoiceFor(OTHER), OTHER);
712
+ expect(posted).toHaveLength(1);
713
+ });
714
+ });
715
+
692
716
  describe("registerClearingClient", () => {
693
717
  it("refuses a request for another URL while a payment is held, naming the held one, then pays the held one once", async () => {
694
718
  const { client: clearing, posted } = wallet({ payments: [lost] });
@@ -201,6 +201,15 @@ export function createClearingClient(config: ClearingClientConfig) {
201
201
  const chain = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
202
202
  let tail: Promise<unknown> = Promise.resolve();
203
203
  let broken = false;
204
+ // The contract writes its clearing unit once, so one successful read serves every payment.
205
+ let unitRead: Promise<bigint> | undefined;
206
+ function unit() {
207
+ unitRead ??= chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "unit" }).catch((error: unknown) => {
208
+ unitRead = undefined;
209
+ throw error;
210
+ });
211
+ return unitRead;
212
+ }
204
213
  async function save(state: ClearingClientState) {
205
214
  try {
206
215
  await config.store.save(state);
@@ -294,15 +303,11 @@ export function createClearingClient(config: ClearingClientConfig) {
294
303
  if (fault) return faulted(cause);
295
304
  if (BigInt(context.epoch) !== epoch) return betweenEpochs(cause);
296
305
  opened = true;
297
- const [{ amount }, unit] = await Promise.all([
306
+ const [{ amount }, units] = await Promise.all([
298
307
  request<{ amount: string }>(`/spendable/${config.signer.publicKey}`),
299
- chain.readContract({
300
- address: config.settlement,
301
- abi: clearingAbi,
302
- functionName: "unit",
303
- }),
308
+ unit(),
304
309
  ]);
305
- covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount), unit);
310
+ covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount), units);
306
311
  } catch (error) {
307
312
  if (!opened && unavailable(error)) return closed(cause);
308
313
  return new Error(
@@ -428,7 +433,7 @@ export function createClearingClient(config: ClearingClientConfig) {
428
433
  )
429
434
  throw new Error("unsupported clearing service deployment");
430
435
  const epoch = BigInt(context.epoch);
431
- const [registered, onchain, fault, admitted, block, unit] =
436
+ const [registered, onchain, fault, admitted, block, units] =
432
437
  await onChain(Promise.all([
433
438
  chain.readContract({
434
439
  address: config.settlement,
@@ -452,11 +457,7 @@ export function createClearingClient(config: ClearingClientConfig) {
452
457
  functionName: "admittedCount",
453
458
  }),
454
459
  chain.getBlock(),
455
- chain.readContract({
456
- address: config.settlement,
457
- abi: clearingAbi,
458
- functionName: "unit",
459
- }),
460
+ unit(),
460
461
  ]));
461
462
  if (fault) throw faulted();
462
463
  // The contract has not registered the service's epoch yet, or has
@@ -484,7 +485,7 @@ export function createClearingClient(config: ClearingClientConfig) {
484
485
  }
485
486
  // The invoice is in token base units; the vector counts clearing units.
486
487
  entry.cumulative = (
487
- BigInt(entry.cumulative) + clearingUnits(amount, unit)
488
+ BigInt(entry.cumulative) + clearingUnits(amount, units)
488
489
  ).toString();
489
490
  entry.count = (BigInt(entry.count) + 1n).toString();
490
491
  // The service takes at most this many recipients in one payment's vector.
@@ -17,6 +17,8 @@ export function metalAccountCode(implementation: Address): Hex {
17
17
  return concat(["0x363d3d373d3d3d363d73", implementation, "0x5af43d82803e903d91602b57fd5bf3"]).toLowerCase() as Hex;
18
18
  }
19
19
 
20
+ const EARLIER_FACTORY = "such as an earlier one";
21
+
20
22
  /** The factory is a caller-selected trust anchor, never inferred from payer-controlled data. */
21
23
  export async function assertMetalAccount(client: PublicClient, account: Address, factory: Address, blockNumber?: bigint): Promise<void> {
22
24
  const at = blockNumber === undefined ? {} : { blockNumber };
@@ -24,7 +26,10 @@ export async function assertMetalAccount(client: PublicClient, account: Address,
24
26
  const code = await client.getCode({ address: account, ...at });
25
27
  const when = blockNumber === undefined ? "" : ` at block ${blockNumber}`;
26
28
  if (code === undefined || code === "0x") throw new Error(`${account} has no code${when}: no account is deployed there`);
27
- if (code.toLowerCase() !== metalAccountCode(implementation)) throw new Error(`${account} is not a MetalAccount clone of the trusted factory ${factory}`);
29
+ if (code.toLowerCase() === metalAccountCode(implementation)) return;
30
+ if (/^0x363d3d373d3d3d363d73[0-9a-f]{40}5af43d82803e903d91602b57fd5bf3$/.test(code.toLowerCase()))
31
+ throw new Error(`${account} was made by another factory than ${factory}, ${EARLIER_FACTORY}`);
32
+ throw new Error(`${account} is not a MetalAccount clone of the trusted factory ${factory}`);
28
33
  }
29
34
 
30
35
  /** The account's own execution, with a quorum of keys. */
@@ -72,7 +77,10 @@ export async function authorizeMandateCall(client: PublicClient, factory: Addres
72
77
  if (first === undefined) throw new Error("no call to authorize");
73
78
  const account = first.to;
74
79
  if (calls.some((c) => !isAddressEqual(c.to, account))) throw new Error("every call in one batch goes to the same account");
75
- await assertMetalAccount(client, account, factory);
80
+ await assertMetalAccount(client, account, factory).catch((error: Error) => {
81
+ // Only the owner can act on it, so only the owner's path says what to do.
82
+ throw error.message.endsWith(EARLIER_FACTORY) ? new Error(`${error.message}: create a new account with this version`, { cause: error }) : error;
83
+ });
76
84
  // A fresh nonce lane per call: two administration calls in flight, or one
77
85
  // beside the owner's own operations on lane 0, never claim one nonce. A
78
86
  // quorum's lane keeps the key's top bit clear; set, it is a lane for