@metalntwx/metal-pay 0.3.1 → 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.
- package/dist/clearing-client.js +15 -14
- package/dist/mandates/account.js +10 -3
- package/dist/networks.js +1 -1
- package/package.json +2 -2
- package/src/clearing-client.test.ts +25 -1
- package/src/clearing-client.ts +15 -14
- package/src/mandates/account.ts +10 -2
- package/src/networks.ts +1 -1
package/dist/clearing-client.js
CHANGED
|
@@ -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 },
|
|
185
|
+
const [{ amount }, units] = await Promise.all([
|
|
177
186
|
request(`/spendable/${config.signer.publicKey}`),
|
|
178
|
-
|
|
179
|
-
address: config.settlement,
|
|
180
|
-
abi: clearingAbi,
|
|
181
|
-
functionName: "unit",
|
|
182
|
-
}),
|
|
187
|
+
unit(),
|
|
183
188
|
]);
|
|
184
|
-
covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount),
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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)
|
package/dist/mandates/account.js
CHANGED
|
@@ -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()
|
|
25
|
-
|
|
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/dist/networks.js
CHANGED
|
@@ -33,7 +33,7 @@ export const METAL_NETWORKS = {
|
|
|
33
33
|
facilitatorUrl: "https://x402-testnet.metalntwx.com/x402/facilitator",
|
|
34
34
|
// What `just estate-deploy-account-factory testnet` recorded in
|
|
35
35
|
// deploy/estate/environments/testnet/account-factory.json for this chain.
|
|
36
|
-
accountFactory: "
|
|
36
|
+
accountFactory: "0x3dbED5DebA965A639bB0Fd40BE96b97922070FA8",
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
/** Resolve a name or a CAIP-2 identifier to a Metal network; undefined for any other chain. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metalntwx/metal-pay",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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] });
|
package/src/clearing-client.ts
CHANGED
|
@@ -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 },
|
|
306
|
+
const [{ amount }, units] = await Promise.all([
|
|
298
307
|
request<{ amount: string }>(`/spendable/${config.signer.publicKey}`),
|
|
299
|
-
|
|
300
|
-
address: config.settlement,
|
|
301
|
-
abi: clearingAbi,
|
|
302
|
-
functionName: "unit",
|
|
303
|
-
}),
|
|
308
|
+
unit(),
|
|
304
309
|
]);
|
|
305
|
-
covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount),
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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.
|
package/src/mandates/account.ts
CHANGED
|
@@ -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()
|
|
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
|
package/src/networks.ts
CHANGED
|
@@ -65,7 +65,7 @@ export const METAL_NETWORKS: Record<MetalNetworkName, MetalNetwork> = {
|
|
|
65
65
|
facilitatorUrl: "https://x402-testnet.metalntwx.com/x402/facilitator",
|
|
66
66
|
// What `just estate-deploy-account-factory testnet` recorded in
|
|
67
67
|
// deploy/estate/environments/testnet/account-factory.json for this chain.
|
|
68
|
-
accountFactory: "
|
|
68
|
+
accountFactory: "0x3dbED5DebA965A639bB0Fd40BE96b97922070FA8",
|
|
69
69
|
},
|
|
70
70
|
};
|
|
71
71
|
|