@metalntwx/metal-agent 0.1.1 → 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.
@@ -1,23 +1,26 @@
1
1
  // `mandate …`: the limits an account's owner sets for an agent, enforced by
2
- // Metal. The owner (the principal) issues a credential naming the agent's
3
- // P-256 key, the most per payment, a budget, an expiry and any payees, then
2
+ // Metal. The owner (the principal) issues a credential naming the mandate
3
+ // key, the most per payment, a budget, an expiry and any payees, then
4
4
  // binds it through the weighted quorum of its MetalAccount.
5
- // From then on the agent pays from that account with its own key alone, and
5
+ // From then on the agent pays from that account with its mandate key alone, and
6
6
  // Mandate Binding refuses anything outside the mandate. Every command takes
7
7
  // the credential file: the account, the mandate's reference and its terms all
8
8
  // derive from it.
9
- import { readFile, writeFile } from "node:fs/promises";
10
- import { FEE_PER_GAS, assertMetalAccount, authorizeMandateCall, metalAccountFactoryAbi, MANDATE_MAX_VALIDITY_SECONDS, MANDATE_VALID_AFTER_LEAD_SECONDS, REFUSALS, UNCAPPED_OCCURRENCES, agentKeyOfJwk, bindCall, createCredential, describeRefusal, encodeAgentKey, extractTerms, hashCredential, ingestCredential, mandateBindingAbi, mandateReference, p256DigestSigner, parseCredential, privateKeyHex, pullCall, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
9
+ import { randomUUID } from "node:crypto";
10
+ import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ import { FEE_PER_GAS, assertMetalAccount, authorizeMandateCall, metalAccountFactoryAbi, MANDATE_MAX_VALIDITY_SECONDS, MANDATE_VALID_AFTER_LEAD_SECONDS, REFUSALS, UNCAPPED_OCCURRENCES, agentKeyOfJwk, bindCall, createCredential, encodeMandateBlob, describeRefusal, encodeAgentKey, extractTerms, hashCredential, ingestCredential, mandateBindingAbi, mandateReference, p256DigestSigner, parseCredential, privateKeyHex, publicMessage, pullCall, readBoundAgentKey, readCheck, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
11
13
  import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
12
- import { BaseError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isHash, parseEventLogs, slice, } from "viem";
14
+ import { BaseError, erc20Abi, getAbiItem, parseAbi, parseAbiItem, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, isHash, parseEventLogs, slice, } from "viem";
13
15
  import { rpcClient } from "../chain.js";
14
- import { agentKeyPath, exists, gasKeyPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
16
+ import { fund } from "./fund.js";
17
+ import { exists, mandateKeyPath, pendingPullsPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
15
18
  export async function loadMandate(credentialPath) {
16
19
  const credential = parseCredential(JSON.parse(await readFile(credentialPath, "utf8")));
17
20
  const terms = extractTerms(credential);
18
21
  return { credential, terms, reference: mandateReference(terms.principal, terms.agentKey, hashCredential(credential)) };
19
22
  }
20
- /** A decimal `*USDZ` amount in whole cents, as a credential states its cap and budget. */
23
+ /** A decimal `*USDZ` amount to the cent, as a credential states its cap and budget. */
21
24
  export function parseCents(text, option) {
22
25
  if (text === undefined || !/^\d+(\.\d{1,2})?$/.test(text) || Number(text) <= 0) {
23
26
  throw new Error(`${option} must be a positive ${USDZ.symbol} amount with at most 2 decimals; got "${text ?? ""}"`);
@@ -29,6 +32,19 @@ export function parseAddress(text, option) {
29
32
  throw new Error(`${option} must be an address; got "${text ?? ""}"`);
30
33
  return getAddress(text);
31
34
  }
35
+ /** `<address>[,<name>[,<website>]]`, as `--payee` takes it. */
36
+ export function parsePayee(text, option) {
37
+ const [address, name, website, ...rest] = (text ?? "").split(",").map((part) => part.trim());
38
+ if (address === undefined || !isAddress(address))
39
+ throw new Error(`${option} must start with an address, then optionally a name and a website, comma-separated; got "${text ?? ""}"`);
40
+ if (rest.length > 0)
41
+ throw new Error(`${option} takes at most an address, a name and a website; got "${text ?? ""}"`);
42
+ if (name === "")
43
+ throw new Error(`${option}'s name is empty; leave it out to show the address; got "${text ?? ""}"`);
44
+ if (website !== undefined && !/^https?:\/\/[^\s]+$/.test(website))
45
+ throw new Error(`${option}'s website must be an http or https URL; got "${website}"`);
46
+ return { address: getAddress(address), ...(name !== undefined && { name }), ...(website !== undefined && { website }) };
47
+ }
32
48
  export function parseTxHash(text, option) {
33
49
  if (!isHash(text))
34
50
  throw new Error(`${option} must be a transaction hash; got "${text}"`);
@@ -46,19 +62,32 @@ export function parseCount(text, option) {
46
62
  const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
47
63
  const utc = (seconds) => new Date(Number(seconds) * 1000).toISOString();
48
64
  const day = (seconds) => new Date(seconds * 1000).toISOString().slice(0, 10);
49
- function printTerms({ principal, agentKey, constraints: c, terms }, print) {
65
+ /**
66
+ * Text from a credential or a facilitator is shown with control, line-separator, bidi and invisible characters replaced, so it cannot
67
+ * start a line, reorder one or hide text; the joiners scripts and emoji need are kept.
68
+ */
69
+ const UNSHOWN = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu;
70
+ function printTerms({ principal, agentKey, constraints: c, terms, descriptive }, print) {
71
+ if (typeof descriptive.promptSummary === "string")
72
+ print(`description ${descriptive.promptSummary.replace(UNSHOWN, "?")}`);
50
73
  print(`principal ${principal}`);
51
- print(`agent-key ${encodeAgentKey(agentKey)} (P-256)`);
74
+ print(`mandate-key ${encodeAgentKey(agentKey)} (P-256)`);
52
75
  print(`token ${c.token}`);
53
76
  print(`per-payment-cap ${usdz(c.perPaymentCap)}`);
54
77
  print(`budget ${usdz(c.budget)} until expiry`);
78
+ print(`starts ${c.recurrenceStart} (${utc(c.recurrenceStart)})`);
55
79
  print(`expiry ${c.expiry} (${utc(c.expiry)})`);
56
80
  if (c.maxOccurrences !== UNCAPPED_OCCURRENCES)
57
81
  print(`max-occurrences ${c.maxOccurrences}`);
58
82
  if (terms.payees.length === 0)
59
83
  print("payees any");
60
- for (const payee of terms.payees)
61
- print(`payee ${payee.account}`);
84
+ const shown = Array.isArray(descriptive.payees) ? descriptive.payees : [];
85
+ for (const payee of terms.payees) {
86
+ const object = shown.find((o) => typeof o.id === "string" && o.id.toLowerCase() === payee.account.toLowerCase());
87
+ const name = typeof object?.name === "string" && object.name.toLowerCase() !== payee.account.toLowerCase() ? ` ${object.name}` : "";
88
+ const website = typeof object?.website === "string" && /^https?:/.test(object.website) ? ` ${object.website}` : "";
89
+ print(`payee ${payee.account}${name}${website}`.replace(UNSHOWN, "?"));
90
+ }
62
91
  }
63
92
  function walletFor(publicClient, account) {
64
93
  if (!publicClient.chain)
@@ -82,54 +111,66 @@ function printSent(target, receipt, print) {
82
111
  }
83
112
  /**
84
113
  * Issues the credential a mandate is bound from, the developer signing it as
85
- * its own issuer; with no payees it lets the agent pay any address within
86
- * the other limits. The agent's keys are written beside the credential unless
87
- * `--agent-key` names a mandate key it already has: the mandate key, and the
88
- * key that pays for its pulls beside that. The issuer's keys are created on
89
- * first use. The budget covers the credential's whole life, from now to its
90
- * expiry.
114
+ * its own issuer; with no payees it lets the agent pay any address within the
115
+ * other limits. The mandate key it names is written beside the credential,
116
+ * unless `mandateKeyFile` names one that exists. The issuer's keys are
117
+ * created on first use. The budget covers the credential's whole life, from
118
+ * now to its expiry.
91
119
  */
92
120
  export async function credentialCreate(target, args, print) {
93
121
  if (await exists(args.out))
94
122
  throw new Error(`${args.out} already exists; pass another --out`);
95
- const agentPath = args.agentKeyPath ?? agentKeyPath(args.out);
96
- if (args.agentKeyPath === undefined && (await exists(agentPath))) {
97
- throw new Error(`${agentPath} already holds a key; pass --agent-key ${agentPath} to name it, or another --out`);
98
- }
99
- const agent = await readOrCreateP256Key(agentPath);
100
- const gasPath = gasKeyPath(agentPath);
101
- const gas = await readOrCreateKey(gasPath);
102
- const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath);
123
+ if (args.startsIn !== undefined && args.startsIn >= args.expiresIn)
124
+ throw new Error(`--starts-in must be less than --expires-in, ${args.expiresIn} seconds`);
125
+ const keyPath = args.mandateKeyFile ?? mandateKeyPath(args.out);
126
+ if (args.mandateKeyFile === undefined && (await exists(keyPath))) {
127
+ throw new Error(`${keyPath} already holds a key; pass --mandate-key-file ${keyPath} to name it, or another --out`);
128
+ }
129
+ if (args.mandateKeyFile !== undefined && !(await exists(keyPath)))
130
+ throw new Error(`no mandate key at ${keyPath}; pass --mandate-key-file with a mandate-key-file credential create printed`);
131
+ const mandateKey = await readOrCreateP256Key(keyPath);
132
+ const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath).catch(async (error) => {
133
+ if (mandateKey.created)
134
+ await rm(keyPath);
135
+ throw error;
136
+ });
103
137
  const now = Math.floor(Date.now() / 1000);
104
138
  const expiresAt = now + args.expiresIn;
105
- const credential = await createCredential({
106
- issuer: keys.issuer,
107
- principal: args.principal,
108
- principalKey: keys.principalKey,
109
- agentKey: agent.key.publicJwk,
110
- perPaymentCap: args.cap,
111
- budget: args.budget,
112
- payees: args.payees.map((address) => ({ address, name: address, website: `eip155:${target.network.chainId}:${address}` })),
113
- recurrence: {
114
- frequency: "ADHO",
115
- startDate: day(now),
116
- endDate: day(expiresAt),
117
- ...(args.maxOccurrences !== undefined && { maxOccurrences: args.maxOccurrences }),
118
- },
119
- issuedAt: now,
120
- expiresAt,
121
- description: args.description,
122
- });
123
- const ingested = await ingestCredential(credential, { trustedIssuers: [trustedIssuer(keys.issuer)], account: args.principal, now });
139
+ let credential;
140
+ let ingested;
141
+ try {
142
+ credential = await createCredential({
143
+ issuer: keys.issuer,
144
+ principal: args.principal,
145
+ principalKey: keys.principalKey,
146
+ agentKey: mandateKey.key.publicJwk,
147
+ perPaymentCap: args.cap,
148
+ budget: args.budget,
149
+ payees: args.payees.map(({ address, name, website }) => ({ address, name: name ?? address, website: website ?? `eip155:${target.network.chainId}:${address}` })),
150
+ recurrence: {
151
+ frequency: "ADHO",
152
+ startDate: args.startsIn === undefined ? day(now) : new Date((now + args.startsIn) * 1000).toISOString().replace(".000Z", "Z"),
153
+ endDate: day(expiresAt),
154
+ ...(args.maxOccurrences !== undefined && { maxOccurrences: args.maxOccurrences }),
155
+ },
156
+ issuedAt: now,
157
+ expiresAt,
158
+ description: args.description,
159
+ });
160
+ ingested = await ingestCredential(credential, { trustedIssuers: [trustedIssuer(keys.issuer)], account: args.principal, now });
161
+ }
162
+ catch (error) {
163
+ // The keys this run created name nothing yet; removing them lets the corrected command run again.
164
+ await Promise.all([mandateKey.created && rm(keyPath), created && rm(args.issuerKeyPath)]);
165
+ throw error;
166
+ }
124
167
  await writeFile(args.out, `${JSON.stringify(credential, null, 2)}\n`, { flag: "wx" });
125
168
  const issuerPublicPath = publicKeyPath(args.issuerKeyPath);
126
169
  await writePublicKeyFile(issuerPublicPath, trustedIssuer(keys.issuer));
127
170
  print(`credential ${args.out}`);
128
- print(`reference ${ingested.reference}`);
129
- printTerms(ingested, print);
130
- print(`agent-key-file ${agentPath}${agent.created ? " (created)" : ""}`);
131
- print(`gas-key-file ${gasPath}${gas.created ? " (created)" : ""}`);
132
- print(`gas-key-address ${gas.address}`);
171
+ print(`mandate ${ingested.reference}`);
172
+ printTerms(ingested.extracted, print);
173
+ print(`mandate-key-file ${keyPath}${mandateKey.created ? " (created)" : ""}`);
133
174
  print(`issuer-key-file ${args.issuerKeyPath}${created ? " (created)" : ""}`);
134
175
  print(`issuer-public-file ${issuerPublicPath}`);
135
176
  }
@@ -150,46 +191,58 @@ async function ingestFor(args) {
150
191
  /** The one-owner account `owner` controls: its config for the factory, and the address the factory gives it. */
151
192
  async function ownerAccount(client, factory, owner) {
152
193
  const config = [[owner], [1n], 1n];
153
- const factoryCode = await client.getCode({ address: factory });
154
- if (factoryCode === undefined || factoryCode === "0x")
194
+ if (!(await hasCode(client, factory)))
155
195
  throw new Error(`no MetalAccountFactory at ${factory}; on a devnet run just deploy, or pass --factory`);
156
196
  const address = await client.readContract({ address: factory, abi: metalAccountFactoryAbi, functionName: "predictAccount", args: config });
157
197
  return { config, address };
158
198
  }
159
- /** Names the owner's one-owner MetalAccount, writing the owner's key first when
160
- * `out` names a file to create; the factory gives each owner one address, and
161
- * the account is created on chain by the owner's first `bind`. Mixed-key and
162
- * weighted accounts use the same factory through the SDK. */
199
+ /** Creates the owner's one-owner MetalAccount on chain, writing the owner's
200
+ * key first when `out` names a file to create. The factory gives each owner
201
+ * one address, so an account that exists is named and nothing is sent. An
202
+ * owner with no gas is funded first, from the faucet on testnet. Mixed-key
203
+ * and weighted accounts use the same factory through the SDK. */
163
204
  export async function accountCreate(target, args, print) {
164
205
  const keyPath = "keyPath" in args ? args.keyPath : args.out;
165
206
  const created = "out" in args ? (await readOrCreateKey(args.out)).created : false;
166
207
  const owner = await readKey(keyPath);
167
208
  const client = rpcClient(target);
168
- const { address } = await ownerAccount(client, args.accountFactory, owner.address);
169
- const code = await client.getCode({ address });
209
+ const { config, address } = await ownerAccount(client, args.accountFactory, owner.address);
170
210
  print(`account ${address}`);
171
- print(`deployed ${code === undefined || code === "0x" ? "no, the first bind creates it" : "yes"}`);
172
211
  print(`owner ${owner.address}`);
173
212
  print(`keyfile ${keyPath}${created ? " (created)" : ""}`);
213
+ if (await hasCode(client, address)) {
214
+ print("deployed yes");
215
+ return;
216
+ }
217
+ if ((await client.getBalance({ address: owner.address })) === 0n) {
218
+ // Only the grant's summary: its transaction lines would read as the account's.
219
+ const funding = [];
220
+ await fund(target, owner.address, undefined, (line) => funding.push(line));
221
+ if (funding[0] !== undefined)
222
+ print(funding[0]);
223
+ }
224
+ const receipt = await send(client, owner, { to: args.accountFactory, data: encodeFunctionData({ abi: metalAccountFactoryAbi, functionName: "createAccount", args: config }), gas: 600000n, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS });
225
+ print("deployed yes");
226
+ printSent(target, receipt, print);
174
227
  }
175
- async function sendOwnerCall(client, args, call, createAccount = false) {
228
+ async function hasCode(client, address) {
229
+ const code = await client.getCode({ address });
230
+ return code !== undefined && code !== "0x";
231
+ }
232
+ async function sendOwnerCall(client, args, call) {
233
+ const account = (Array.isArray(call) ? call[0] : call)?.to;
234
+ if (account === undefined)
235
+ throw new Error("no account call to send");
176
236
  const sponsor = await readKey(args.keyPath);
177
237
  const signers = await Promise.all((args.signerPaths.length === 0 ? [args.keyPath] : args.signerPaths).map(readKey));
178
238
  signers.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()));
179
239
  if (new Set(signers.map(s => s.address.toLowerCase())).size !== signers.length)
180
240
  throw new Error("duplicate account signer");
181
- // A one-owner account is created by its owner's first bind; the sponsor pays.
182
- // One with several signers is created by the factory beforehand, with its
183
- // signers, weights and threshold, which these key files alone do not name.
184
- const code = createAccount ? await client.getCode({ address: call.to }) : "0x01";
185
- if ((code === undefined || code === "0x") && args.signerPaths.length > 0) {
186
- throw new Error(`${call.to} has no code: create the account first, through MetalAccountFactory.createAccount with its signers, weights and threshold; only a one-owner account is created by its owner's first bind`);
187
- }
188
- if (code === undefined || code === "0x") {
189
- const { config, address } = await ownerAccount(client, args.accountFactory, sponsor.address);
190
- if (address.toLowerCase() !== call.to.toLowerCase())
191
- throw new Error(`${call.to} is not the account of ${sponsor.address}; it has no code, and only its owner's first bind creates it`);
192
- await send(client, sponsor, { to: args.accountFactory, data: encodeFunctionData({ abi: metalAccountFactoryAbi, functionName: "createAccount", args: config }), gas: 600000n, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS });
241
+ if (!(await hasCode(client, account))) {
242
+ const own = signers.length === 1 && isAddressEqual(signers[0].address, sponsor.address) && isAddressEqual((await ownerAccount(client, args.accountFactory, sponsor.address)).address, account);
243
+ if (own)
244
+ throw new Error(`${account} has no account: create it with metal-agent account create`);
245
+ throw new Error(`${account} has no account, and is not ${sponsor.address}'s one-owner account: create it through MetalAccountFactory.createAccount with its owners, weights and threshold`);
193
246
  }
194
247
  const authorized = await authorizeMandateCall(client, args.accountFactory, call, async (digest) => encodeAbiParameters(parseAbiParameters("bytes[], bytes[]"), [signers.map(s => s.address), await Promise.all(signers.map(s => s.sign({ hash: digest })))]));
195
248
  return send(client, sponsor, authorized);
@@ -197,15 +250,24 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
197
250
  export async function mandateBind(target, args, print) {
198
251
  const { ingested } = await ingestFor(args);
199
252
  const publicClient = rpcClient(target);
200
- const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested), true);
253
+ const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested));
201
254
  print(`bound ${ingested.reference}`);
202
255
  printSent(target, receipt, print);
203
256
  }
257
+ /** Checks that the mandate a replace or renew ends is bound and not yet revoked, which the account's revert would not say. */
258
+ async function assertLive(publicClient, mandate, path) {
259
+ const state = await publicClient.readContract({ address: mandate.terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [mandate.reference] });
260
+ if (!isAddressEqual(state.principal, mandate.terms.principal))
261
+ throw new Error(`${path} is not bound on ${mandate.terms.principal}; bind the new credential with mandate bind`);
262
+ if (state.revoked)
263
+ throw new Error(`${path} is already revoked; bind the new credential with mandate bind`);
264
+ }
204
265
  /** Binds the credential as the successor of the mandate `replacesPath` describes, which is revoked in the same transaction; the successor carries its spend. */
205
266
  export async function mandateReplace(target, args, print) {
206
267
  const { ingested } = await ingestFor(args);
207
268
  const predecessor = await loadMandate(args.replacesPath);
208
269
  const publicClient = rpcClient(target);
270
+ await assertLive(publicClient, predecessor, args.replacesPath);
209
271
  const carried = await readMandateState(publicClient, predecessor.terms.principal, predecessor.reference);
210
272
  if (carried.spent >= ingested.constraints.budget)
211
273
  throw new Error(`the replacement's budget of ${usdz(ingested.constraints.budget)} is not above the ${usdz(carried.spent)} already spent, which it carries`);
@@ -218,6 +280,28 @@ export async function mandateReplace(target, args, print) {
218
280
  print(`spent ${usdz(state.spent)} of ${usdz(ingested.constraints.budget)}`);
219
281
  printSent(target, receipt, print);
220
282
  }
283
+ /**
284
+ * Binds the credential as a fresh mandate and revokes the one `replacesPath`
285
+ * describes, in one account batch: the new budget starts at zero spent, as a
286
+ * budget never resets on its own. `replace` carries the spend over instead.
287
+ */
288
+ export async function mandateRenew(target, args, print) {
289
+ const { ingested } = await ingestFor(args);
290
+ const predecessor = await loadMandate(args.replacesPath);
291
+ if (predecessor.reference === ingested.reference)
292
+ throw new Error(`${args.replacesPath} is the mandate being bound; to end it, use mandate revoke`);
293
+ if (predecessor.terms.principal.toLowerCase() !== ingested.principal.toLowerCase()) {
294
+ throw new Error(`${args.replacesPath} is a mandate on ${predecessor.terms.principal}, not on ${ingested.principal}`);
295
+ }
296
+ const publicClient = rpcClient(target);
297
+ await assertLive(publicClient, predecessor, args.replacesPath);
298
+ const receipt = await sendOwnerCall(publicClient, args, [bindCall(ingested), revokeCall(ingested.principal, predecessor.reference)]);
299
+ const state = await readMandateState(publicClient, ingested.principal, ingested.reference);
300
+ print(`revoked ${predecessor.reference}`);
301
+ print(`bound ${ingested.reference}`);
302
+ print(`spent ${usdz(state.spent)} of ${usdz(ingested.constraints.budget)}`);
303
+ printSent(target, receipt, print);
304
+ }
221
305
  export async function mandateRevoke(target, args, print) {
222
306
  const { reference, terms } = await loadMandate(args.credentialPath);
223
307
  const receipt = await sendOwnerCall(rpcClient(target), args, revokeCall(terms.principal, reference));
@@ -228,11 +312,10 @@ export async function mandateRevoke(target, args, print) {
228
312
  export async function mandateStatus(target, credentialPath, accountFactory, print) {
229
313
  const { terms, reference } = await loadMandate(credentialPath);
230
314
  const publicClient = rpcClient(target);
231
- print(`reference ${reference}`);
315
+ print(`mandate ${reference}`);
232
316
  printTerms(terms, print);
233
- const code = await publicClient.getCode({ address: terms.principal });
234
- if (code === undefined || code === "0x") {
235
- print("account-type none yet; the owner's first bind creates it");
317
+ if (!(await hasCode(publicClient, terms.principal))) {
318
+ print("account-type none; create the account before binding: metal-agent account create for one owner");
236
319
  print("bound no");
237
320
  return;
238
321
  }
@@ -250,7 +333,7 @@ export async function mandateStatus(target, credentialPath, accountFactory, prin
250
333
  /**
251
334
  * Verifies the mandate and its payments from the chain alone: the binding on
252
335
  * the principal's account, the credential against it and under its issuer,
253
- * and each payment and refusal as the agent key's own per-payment mandate,
336
+ * and each payment and refusal as the mandate key's own per-payment mandate,
254
337
  * rebuilt from the transaction that carried it. Any party holding the
255
338
  * credential can run it; Metal is not asked.
256
339
  */
@@ -263,25 +346,47 @@ export async function mandateVerify(target, args, print) {
263
346
  const v = args.tx === undefined
264
347
  ? await verifyMandate(publicClient, opts)
265
348
  : await verifyMandatePayment(publicClient, { ...opts, tx: args.tx, ...(args.signedOffer === undefined ? {} : { checkoutMandate: args.signedOffer }) });
266
- print(`reference ${reference}`);
349
+ print(`mandate ${reference}`);
267
350
  for (const step of v.steps) {
268
351
  print(`step ${step.step} ${step.outcome}: ${step.title}`);
269
352
  print(` ${step.detail}`);
270
353
  }
271
354
  for (const p of v.payments) {
272
355
  const what = p.kind === "payment" ? "payment" : `refusal (${p.reason === undefined || p.reason === "None" ? "?" : REFUSALS[p.reason].reason})`;
273
- const how = p.direct ? "pull" : `nonce ${p.nonce ?? "?"}`;
274
- print(`${what} ${p.txHash} block ${p.block}: ${usdz(p.amount)} to ${p.to}, ${how}${p.problems.length === 0 ? "" : `: ${p.problems.join(", ")}`}`);
356
+ print(`${what} ${p.txHash} block ${p.block}: ${usdz(p.amount)} to ${p.to}, nonce ${p.nonce ?? "?"}${p.problems.length === 0 ? "" : `: ${p.problems.join(", ")}`}`);
275
357
  }
276
358
  print(`verified ${v.ok ? "yes" : "no"}`);
277
359
  return v.ok ? "verified" : "failed";
278
360
  }
279
- /** The agent's P-256 key, checked to be the one the mandate names. */
280
- export async function mandateAgentSigner(mandate, agentKeyPath) {
281
- const agent = await readP256Key(agentKeyPath);
282
- if (!sameAgentKey(agentKeyOfJwk(agent.publicJwk), mandate.terms.agentKey))
283
- throw new Error(`${agentKeyPath} is not the mandate's agent key`);
284
- return p256DigestSigner(privateKeyHex(agent));
361
+ /**
362
+ * Signs with the mandate's key: `keyFile` when given, or else the key file in
363
+ * the credential's directory that the credential names, so a replacement
364
+ * written beside its predecessor signs with the predecessor's key.
365
+ */
366
+ export async function mandateAgentSigner(mandate, credentialPath, keyFile) {
367
+ const names = (key) => sameAgentKey(agentKeyOfJwk(key.publicJwk), mandate.terms.agentKey);
368
+ if (keyFile !== undefined) {
369
+ const key = await readP256Key(keyFile).catch((error) => {
370
+ throw error.code === "ENOENT" ? new Error(`no mandate key at ${keyFile}; pass --mandate-key-file with the mandate-key-file credential create printed`) : error;
371
+ });
372
+ if (!names(key))
373
+ throw new Error(`${keyFile} is not this mandate's key`);
374
+ return p256DigestSigner(privateKeyHex(key));
375
+ }
376
+ const dir = dirname(credentialPath);
377
+ const own = mandateKeyPath(credentialPath);
378
+ const beside = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".key.json")).map((name) => join(dir, name));
379
+ for (const path of [own, ...beside.filter((path) => path !== own)]) {
380
+ // The credential's own key file must read; another file that is not a P-256 key is not this mandate's.
381
+ const key = await readP256Key(path).catch((error) => {
382
+ if (path === own && error.code !== "ENOENT")
383
+ throw error;
384
+ return undefined;
385
+ });
386
+ if (key && names(key))
387
+ return p256DigestSigner(privateKeyHex(key));
388
+ }
389
+ throw new Error(`no key beside ${credentialPath} is this mandate's; pass --mandate-key-file with the mandate-key-file credential create printed`);
285
390
  }
286
391
  /** A revert's Mandate Binding error name (`NonceAlreadyUsed`, `BadAgentSignature`, …), or the node's message. */
287
392
  function revertName(error) {
@@ -299,75 +404,403 @@ function revertName(error) {
299
404
  return error.shortMessage;
300
405
  }
301
406
  /**
302
- * A direct payment under the mandate, with no seller: the agent signs it and
303
- * the `--key` account submits it to `pull` on the principal's account and
304
- * pays the gas. It settles, or Mandate Binding records its refusal with the
305
- * reason, in a transaction that succeeds; the outcome is returned so a
306
- * refusal can fail the command.
407
+ * A direct payment under the mandate, with no seller: the agent signs it, and
408
+ * the facilitator, or the `--key` account, submits it to `pull` on the
409
+ * principal's account and pays the gas. One the mandate refuses when checked
410
+ * is reported and not sent, unless `recordRefusal` asks for Mandate Binding to
411
+ * record the refusal on chain.
412
+ *
413
+ * A pull that has left can land until its window ends, whatever the
414
+ * facilitator answers, so its outcome is read from the chain alone. One with
415
+ * no outcome yet is kept beside the credential, and the same command sends
416
+ * that same pull again, which the account pays at most once; once it can no
417
+ * longer land, the command signs a new one.
307
418
  */
308
419
  export async function mandatePull(target, args, print) {
309
420
  const mandate = await loadMandate(args.credentialPath);
310
- const signDigest = await mandateAgentSigner(mandate, args.agentKeyPath);
311
- const sender = await readKey(args.keyPath).catch((error) => {
312
- if (error.code === "ENOENT")
313
- throw new Error(`no key at ${args.keyPath} to submit the pull and pay its gas; pass --key <funded key file>`);
314
- throw error;
315
- });
421
+ const signDigest = await mandateAgentSigner(mandate, args.credentialPath, args.mandateKeyFile);
422
+ const keyPath = args.keyPath;
423
+ if (args.recordRefusal && keyPath === undefined)
424
+ throw new Error("--record-refusal sends the refused pull itself: pass --key <funded key file> to submit it and pay its gas");
425
+ const facilitatorProtocol = URL.canParse(target.facilitatorUrl) ? new URL(target.facilitatorUrl).protocol : "";
426
+ if (keyPath === undefined && facilitatorProtocol !== "http:" && facilitatorProtocol !== "https:") {
427
+ throw new Error(`the facilitator URL ${target.facilitatorUrl} is not an http or https URL, so the pull was not sent`);
428
+ }
429
+ const sender = keyPath === undefined
430
+ ? undefined
431
+ : await readKey(keyPath).catch((error) => {
432
+ if (error.code === "ENOENT")
433
+ throw new Error(`no key at ${keyPath} to submit the pull and pay its gas; pass --key <funded key file>, or leave it out and the facilitator submits the pull`);
434
+ throw error;
435
+ });
316
436
  const { terms, reference } = mandate;
317
- const chainId = target.network.chainId;
318
- const now = BigInt(Math.floor(Date.now() / 1000));
319
- const signed = await signL3a({
320
- chainId,
321
- ref: reference,
322
- token: terms.constraints.token,
323
- terms: terms.terms,
324
- to: args.to,
325
- value: args.amount,
326
- validAfter: now - BigInt(MANDATE_VALID_AFTER_LEAD_SECONDS),
327
- validBefore: now + BigInt(MANDATE_MAX_VALIDITY_SECONDS),
328
- transactionId: transactionIdOf({
437
+ const publicClient = rpcClient(target);
438
+ // A mandate not yet bound, or before its dates, can stop refusing; these are
439
+ // checked before the agent signs, so a pull reported unsent can never pay later.
440
+ const [bound, block, gas] = await Promise.all([
441
+ hasCode(publicClient, terms.principal).then((deployed) => (deployed ? readBoundAgentKey(publicClient, terms.principal, reference) : undefined)),
442
+ publicClient.getBlock(),
443
+ sender === undefined ? undefined : publicClient.getBalance({ address: sender.address }),
444
+ ]);
445
+ if (bound === undefined)
446
+ throw new Error(`the mandate ${reference} is not bound on ${terms.principal}; the owner binds it with mandate bind`);
447
+ if (sender !== undefined && gas === 0n)
448
+ throw new Error(`${sender.address}, which submits the pull, holds no gas: fund it, or leave out --key and the facilitator submits the pull`);
449
+ const keptPath = join(pendingPullsPath(args.credentialPath), `${reference}-${args.to.toLowerCase()}-${args.amount}`);
450
+ const end = await chainEnd(keptPath);
451
+ let attempt;
452
+ // The pull a new one follows in the chain; the chain's first when there is none.
453
+ let follows;
454
+ let checked;
455
+ if (end !== undefined && (await exists(join(keptPath, `${end.pull.nonce}.done`)))) {
456
+ // Its outcome is reported: this is a new payment.
457
+ follows = end.pull.nonce;
458
+ }
459
+ else if (end !== undefined) {
460
+ attempt = end;
461
+ const { pull } = attempt;
462
+ if (!isAddressEqual(pull.to, args.to) || pull.value !== args.amount || !isAddressEqual(pull.token, terms.constraints.token) || slice(pull.nonce, 0, 12) !== slice(reference, 0, 12)) {
463
+ throw new Error(`${keptPath} holds a pull for another payment than this mandate's ${usdz(args.amount)} to ${args.to}`);
464
+ }
465
+ const found = await outcomeOnChain(publicClient, terms, attempt);
466
+ if (found.kind === "landed")
467
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
468
+ if (found.kind === "open" && found.landed)
469
+ return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
470
+ // One that can never land is followed by a new pull, signed and checked below.
471
+ if (found.kind === "never") {
472
+ follows = attempt.pull.nonce;
473
+ attempt = undefined;
474
+ }
475
+ }
476
+ if (attempt === undefined) {
477
+ if (block.timestamp < terms.constraints.recurrenceStart && !args.recordRefusal)
478
+ return printUnsent("OutsideRecurrence", print);
479
+ const now = BigInt(Math.floor(Date.now() / 1000));
480
+ // What a seller's offer would be; the facilitator settles the pull against it.
481
+ const requirements = {
329
482
  scheme: "exact",
330
- network: `eip155:${chainId}`,
483
+ network: `eip155:${target.network.chainId}`,
331
484
  amount: args.amount.toString(),
332
485
  asset: terms.constraints.token,
333
486
  payTo: args.to,
334
487
  maxTimeoutSeconds: MANDATE_MAX_VALIDITY_SECONDS,
335
- }),
336
- iss: terms.terms.kid,
337
- signDigest,
488
+ };
489
+ const signed = await signL3a({
490
+ chainId: target.network.chainId,
491
+ ref: reference,
492
+ token: terms.constraints.token,
493
+ terms: terms.terms,
494
+ to: args.to,
495
+ value: args.amount,
496
+ validAfter: now - BigInt(MANDATE_VALID_AFTER_LEAD_SECONDS),
497
+ validBefore: now + BigInt(MANDATE_MAX_VALIDITY_SECONDS),
498
+ transactionId: transactionIdOf(requirements),
499
+ iss: terms.terms.kid,
500
+ signDigest,
501
+ });
502
+ const fresh = { pull: signed.pull, fields: signed.fields, requirements, fromBlock: block.number };
503
+ const reason = await readCheck(publicClient, terms.principal, fresh.pull, fresh.fields);
504
+ if (reason !== "None" && !args.recordRefusal)
505
+ return printUnsent(reason, print);
506
+ await simulatePull(publicClient, terms, fresh, sender);
507
+ checked = fresh.pull.nonce;
508
+ // Kept before it leaves: from here on it can land, and the same command sends it again.
509
+ // A run that kept one first wins, and this run sends that one.
510
+ attempt = await keepFirst(join(keptPath, follows === undefined ? "first.json" : `${follows}.next.json`), fresh);
511
+ }
512
+ if (attempt.pull.nonce !== checked) {
513
+ const rejected = await simulatePull(publicClient, terms, attempt, sender).then(() => undefined, (error) => error);
514
+ if (rejected !== undefined) {
515
+ // A pull another run kept, or one kept before, can have landed since it was read.
516
+ const found = await outcomeOnChain(publicClient, terms, attempt);
517
+ if (found.kind === "landed") {
518
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
519
+ }
520
+ if (found.kind === "open" && found.landed)
521
+ return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
522
+ throw rejected;
523
+ }
524
+ }
525
+ const call = pullCall(terms.principal, attempt.pull, attempt.fields);
526
+ const sent = sender === undefined ? await sendToFacilitator(target, terms, attempt) : await sendFromKey(publicClient, sender, call);
527
+ if (sent.kind === "unsent")
528
+ throw new Error(`the pull was not sent: ${sent.cause}; it is kept at ${keptPath}, and the same command sends it`);
529
+ const found = await outcomeOnChain(publicClient, terms, attempt, sent.transaction);
530
+ if (found.kind === "landed") {
531
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
532
+ }
533
+ if (found.kind === "never") {
534
+ print("outcome refused, not paid");
535
+ print(`reason ${describeRefusal(found.reason)}`);
536
+ return settle(keptPath, attempt, "refused");
537
+ }
538
+ // A transaction whose receipt was read and does not carry this pull is not named as its.
539
+ if (sent.transaction !== undefined && found.unreadReceipt)
540
+ print(`transaction ${sent.transaction}`);
541
+ if (found.landed)
542
+ return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
543
+ return printPending(print, found.unread === undefined ? sent.why : `${sent.why}; the chain could not be read: ${found.unread}`, attempt, keptPath);
544
+ }
545
+ /**
546
+ * Marks a kept pull's outcome reported once it is printed, so the same
547
+ * command makes a new payment next; a run killed between the two leaves it
548
+ * for the next run to report.
549
+ */
550
+ async function settle(keptPath, kept, outcome) {
551
+ await writeFile(join(keptPath, `${kept.pull.nonce}.done`), "", { flag: "wx", mode: 0o600 }).catch((error) => {
552
+ if (error.code !== "EEXIST")
553
+ throw error;
338
554
  });
339
- const publicClient = rpcClient(target);
340
- const call = pullCall(terms.principal, signed.pull, signed.fields);
555
+ return outcome;
556
+ }
557
+ /**
558
+ * The last pull in a payee and amount's chain: its first, then each pull kept
559
+ * after one that can never land or whose outcome is reported. Pulls are only
560
+ * ever added to a chain, each once, so runs at once read the same end.
561
+ */
562
+ async function chainEnd(keptPath) {
563
+ let kept = await readKept(join(keptPath, "first.json"));
564
+ for (let next = kept; next !== undefined; next = await readKept(join(keptPath, `${next.pull.nonce}.next.json`)))
565
+ kept = next;
566
+ return kept;
567
+ }
568
+ function printPending(print, reason, kept, keptPath) {
569
+ print("outcome pending");
570
+ print(`reason ${reason.replace(UNSHOWN, "?").slice(0, 300)}`);
571
+ print(`can-land-until ${utc(kept.pull.validBefore)}`);
572
+ print(`kept ${keptPath}: run the same command again to send the same pull, which pays at most once`);
573
+ return "pending";
574
+ }
575
+ const BIGINTS = ["value", "validAfter", "validBefore", "fromBlock"];
576
+ async function readKept(path) {
577
+ const text = await readFile(path, "utf8").catch((error) => {
578
+ if (error.code === "ENOENT")
579
+ return undefined;
580
+ throw error;
581
+ });
582
+ if (text === undefined)
583
+ return undefined;
341
584
  try {
342
- // At the node's own gas ceiling: what the pull costs is sendMandateCall's to estimate.
343
- await publicClient.call({ account: sender.address, to: call.to, data: call.data });
585
+ return JSON.parse(text, (key, value) => (BIGINTS.includes(key) && typeof value === "string" ? BigInt(value) : value));
344
586
  }
345
587
  catch (error) {
588
+ throw new Error(`${path} cannot be read: ${error instanceof Error ? error.message : String(error)}; it holds a pull that can land until ten minutes after it was signed`);
589
+ }
590
+ }
591
+ /**
592
+ * Keeps `pull` at `path` unless a pull is kept there already, and returns the
593
+ * one kept. The whole file is written beside it and linked into place, which
594
+ * fails when the path exists, so two runs at once keep and send one pull.
595
+ */
596
+ async function keepFirst(path, pull) {
597
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
598
+ const draft = await writeDraft(path, pull);
599
+ try {
600
+ for (;;) {
601
+ const linked = await link(draft, path).then(() => true, (error) => {
602
+ if (error.code !== "EEXIST")
603
+ throw error;
604
+ return false;
605
+ });
606
+ if (linked)
607
+ return pull;
608
+ const kept = await readKept(path);
609
+ if (kept !== undefined)
610
+ return kept;
611
+ }
612
+ }
613
+ finally {
614
+ await rm(draft, { force: true });
615
+ }
616
+ }
617
+ async function writeDraft(path, pull) {
618
+ const draft = `${path}.${randomUUID()}.tmp`;
619
+ await writeFile(draft, `${JSON.stringify(pull, (_, value) => (typeof value === "bigint" ? value.toString() : value), 2)}\n`, { mode: 0o600 });
620
+ return draft;
621
+ }
622
+ /** The pull's own call at the node's gas ceiling; what it costs is sendMandateCall's to estimate. */
623
+ async function simulatePull(publicClient, terms, kept, sender) {
624
+ const call = pullCall(terms.principal, kept.pull, kept.fields);
625
+ try {
626
+ await publicClient.call({ ...(sender && { account: sender.address }), to: call.to, data: call.data });
627
+ }
628
+ catch (error) {
629
+ if (lostInTransport(error))
630
+ throw new Error(`the pull could not be checked: ${publicMessage(error)}`);
346
631
  throw new Error(`Mandate Binding rejects the pull and records nothing: ${revertName(error)}`);
347
632
  }
348
- const receipt = await send(publicClient, sender, call);
349
- const outcome = parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"] }).find((log) => log.address.toLowerCase() === terms.principal.toLowerCase() && log.args.l3aDigest === slice(signed.pull.nonce, 12));
350
- if (outcome?.eventName === "MandatePayment") {
351
- print("outcome paid");
633
+ }
634
+ /** Above the facilitator's own 60 s wait for a receipt, so its answer is not cut short. */
635
+ const FACILITATOR_TIMEOUT_MS = 120_000;
636
+ /**
637
+ * The codes a connection fails with before any request is sent: the host is
638
+ * not there, or is not who its certificate says. A failure after that, or a
639
+ * timeout, leaves open whether the request arrived.
640
+ */
641
+ const NEVER_REACHED = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ERR_TLS_CERT_ALTNAME_INVALID", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN"]);
642
+ /** The code under which `fetch` never reached its host, or undefined when the request may have arrived. */
643
+ function neverReached(error) {
644
+ const cause = error.cause;
645
+ const code = typeof cause?.code === "string" ? cause.code : undefined;
646
+ return code !== undefined && NEVER_REACHED.has(code) ? code : undefined;
647
+ }
648
+ /**
649
+ * Hands the pull to the facilitator as the payment a seller would settle,
650
+ * `exact` to the payee; it checks the pull against the mandate, submits it and
651
+ * pays the gas. Its answer gives the transaction it sent, if any, and its
652
+ * reason for not settling; whether the pull paid is the chain's to say.
653
+ */
654
+ async function sendToFacilitator(target, terms, kept) {
655
+ const paymentPayload = {
656
+ x402Version: 2,
657
+ accepted: kept.requirements,
658
+ payload: {
659
+ authorization: {
660
+ from: terms.principal,
661
+ to: kept.pull.to,
662
+ value: kept.pull.value.toString(),
663
+ validAfter: kept.pull.validAfter.toString(),
664
+ validBefore: kept.pull.validBefore.toString(),
665
+ nonce: kept.pull.nonce,
666
+ },
667
+ signature: encodeMandateBlob(kept.pull, kept.fields),
668
+ },
669
+ };
670
+ let response;
671
+ try {
672
+ response = await fetch(`${target.facilitatorUrl}/settle`, {
673
+ method: "POST",
674
+ headers: { "content-type": "application/json" },
675
+ body: JSON.stringify({ x402Version: 2, paymentPayload, paymentRequirements: kept.requirements }),
676
+ signal: AbortSignal.timeout(FACILITATOR_TIMEOUT_MS),
677
+ });
678
+ }
679
+ catch (error) {
680
+ const code = neverReached(error);
681
+ if (code !== undefined)
682
+ return { kind: "unsent", cause: `the facilitator at ${new URL(target.facilitatorUrl).host} could not be reached (${code})` };
683
+ return { kind: "sent", transaction: undefined, why: "the facilitator did not answer" };
684
+ }
685
+ const answer = await response.json().catch(() => undefined);
686
+ const settled = (typeof answer === "object" && answer !== null ? answer : {});
687
+ const transaction = typeof settled.transaction === "string" && isHash(settled.transaction) ? settled.transaction : undefined;
688
+ const said = [settled.errorReason, settled.errorMessage].filter((part) => typeof part === "string" && part !== "").join(": ");
689
+ const why = said !== "" ? `the facilitator says ${said}` : settled.success === true ? "the facilitator says it settled" : `the facilitator answered HTTP ${response.status}`;
690
+ return { kind: "sent", transaction, why: why.replace(UNSHOWN, "?").slice(0, 300) };
691
+ }
692
+ /**
693
+ * Submits the pull from the `--key` account, which pays the gas. A node that
694
+ * refuses the transaction, or an estimate that fails, sent nothing; a request
695
+ * lost on its way to the node may have.
696
+ */
697
+ async function sendFromKey(publicClient, sender, call) {
698
+ return sendMandateCall(publicClient, walletFor(publicClient, sender), call).then((transaction) => ({ kind: "sent", transaction, why: "its transaction has no receipt" }), (error) => (lostInTransport(error) ? { kind: "sent", transaction: undefined, why: `sending it failed: ${publicMessage(error)}` } : { kind: "unsent", cause: `sending it failed: ${publicMessage(error)}` }));
699
+ }
700
+ const PULL_OUTCOMES = [getAbiItem({ abi: mandateBindingAbi, name: "MandatePayment" }), getAbiItem({ abi: mandateBindingAbi, name: "MandateRefused" })];
701
+ /** Blocks per log search, under the node's own limit on one filter's range. */
702
+ const LOG_CHUNK = 10000n;
703
+ /**
704
+ * The reasons a bound mandate refuses a pull for good: none of them can turn
705
+ * into a payment later. A closed window is read from the block itself; a
706
+ * window or mandate this node has not reached yet is not final.
707
+ */
708
+ const FINAL = ["Revoked", "Expired", "BadSignature", "PayeeNotAllowed", "OverPerPaymentCap", "OverBudget", "OverOccurrences"];
709
+ /**
710
+ * Where a pull stands on chain, read at one block: landed, in `transaction`
711
+ * when one is named or in the account's own log between the block it was
712
+ * signed at and the end of its window; never to land, once its window has
713
+ * closed or the mandate refuses it for good; or still open. A read that fails
714
+ * leaves it open.
715
+ */
716
+ async function outcomeOnChain(publicClient, terms, kept, transaction) {
717
+ const digest = slice(kept.pull.nonce, 12);
718
+ let unreadReceipt = transaction !== undefined;
719
+ try {
720
+ if (transaction !== undefined) {
721
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: transaction, pollingInterval: 250 });
722
+ unreadReceipt = false;
723
+ if (pullEventOf(terms, digest, receipt) !== undefined)
724
+ return { kind: "landed", receipt };
725
+ }
726
+ const head = await publicClient.getBlock();
727
+ // A pull lands only before its window ends, so the search stops at the first range that reaches it.
728
+ for (let from = kept.fromBlock; from <= head.number; from += LOG_CHUNK) {
729
+ const to = from + LOG_CHUNK - 1n < head.number ? from + LOG_CHUNK - 1n : head.number;
730
+ const logs = await publicClient.getLogs({ address: terms.principal, events: PULL_OUTCOMES, fromBlock: from, toBlock: to });
731
+ const log = logs.find((entry) => entry.args.l3aDigest === digest);
732
+ if (log !== undefined)
733
+ return { kind: "landed", receipt: await publicClient.getTransactionReceipt({ hash: log.transactionHash }) };
734
+ if (to === head.number || (await publicClient.getBlock({ blockNumber: to })).timestamp >= kept.pull.validBefore)
735
+ break;
736
+ }
737
+ // The token records the authorization a paid pull spends, whatever the mandate's state is since.
738
+ const spent = await publicClient.readContract({ address: kept.pull.token, abi: AUTHORIZATION_STATE, functionName: "authorizationState", args: [terms.principal, kept.pull.nonce], blockNumber: head.number });
739
+ if (spent)
740
+ return { kind: "open", landed: true };
741
+ if (head.timestamp >= kept.pull.validBefore)
742
+ return { kind: "never", reason: "OutsideWindow" };
743
+ const reason = await readCheck(publicClient, terms.principal, kept.pull, kept.fields, head.number);
744
+ // After its end a mandate refuses for good; before its start it does only until then.
745
+ // A used digest with no log for it is a pull that landed, as a payment or a recorded refusal.
746
+ if (reason === "NonceUsed")
747
+ return { kind: "open", landed: true };
748
+ const final = FINAL.includes(reason) || (reason === "OutsideRecurrence" && head.timestamp >= terms.constraints.recurrenceStart);
749
+ return final ? { kind: "never", reason: reason } : { kind: "open" };
750
+ }
751
+ catch (error) {
752
+ return { kind: "open", unread: publicMessage(error), unreadReceipt };
753
+ }
754
+ }
755
+ const AUTHORIZATION_STATE = parseAbi(["function authorizationState(address authorizer, bytes32 nonce) view returns (bool)"]);
756
+ /** Whether the request failed on its way, so the node may or may not have acted on it. */
757
+ function lostInTransport(error) {
758
+ return error instanceof BaseError && error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError) !== null;
759
+ }
760
+ function printUnsent(reason, print) {
761
+ print("outcome refused, not sent");
762
+ print(`reason ${describeRefusal(reason)}`);
763
+ print("recorded no");
764
+ return "refused";
765
+ }
766
+ function pullEventOf(terms, l3aDigest, receipt) {
767
+ return parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"] }).find((log) => isAddressEqual(log.address, terms.principal) && log.args.l3aDigest === l3aDigest);
768
+ }
769
+ /** Where a token sends a credit the payee's receive policy refuses: its escrow (IMIP20.sol). */
770
+ const RECEIVE_POLICY = "0x0000000000000000000000000000000000000f4d";
771
+ const AUTHORIZATION_USED = parseAbiItem("event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)");
772
+ function printPull(target, terms, pull, receipt, print) {
773
+ const outcome = pullEventOf(terms, slice(pull.nonce, 12), receipt);
774
+ if (outcome === undefined)
775
+ throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
776
+ let result = "refused";
777
+ print(`transaction ${receipt.transactionHash}`);
778
+ if (outcome.eventName === "MandatePayment") {
779
+ // The transfer the pull's authorization makes is the token's next one from the account after it.
780
+ const used = parseEventLogs({ abi: [AUTHORIZATION_USED], logs: receipt.logs }).find((log) => isAddressEqual(log.address, pull.token) && isAddressEqual(log.args.authorizer, terms.principal) && log.args.nonce === pull.nonce);
781
+ const moved = used && parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: receipt.logs }).find((log) => isAddressEqual(log.address, pull.token) && log.logIndex > used.logIndex && isAddressEqual(log.args.from, terms.principal) && !isAddressEqual(log.args.to, pull.token));
782
+ result = moved !== undefined && isAddressEqual(moved.args.to, RECEIVE_POLICY) ? "held" : "paid";
783
+ print(`outcome ${result}`);
784
+ if (result === "held")
785
+ print("reason the payee's receive policy refused the payment, and the token holds it in escrow");
352
786
  print(`spent ${usdz(outcome.args.spent)} of ${usdz(terms.constraints.budget)}`);
353
787
  }
354
- else if (outcome?.eventName === "MandateRefused") {
788
+ else {
355
789
  print("outcome refused");
356
790
  const reason = reasonName(outcome.args.reason);
357
791
  if (reason === "None")
358
792
  throw new Error(`transaction ${receipt.transactionHash} records a refusal with no reason`);
359
793
  print(`reason ${describeRefusal(reason)}`);
360
794
  }
361
- else {
362
- throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
363
- }
364
795
  print(`payer ${terms.principal}`);
365
- print(`payee ${signed.pull.to}`);
366
- print(`amount ${usdz(signed.pull.value)}`);
367
- printSent(target, receipt, print);
368
- return outcome.eventName === "MandatePayment" ? "paid" : "refused";
796
+ print(`payee ${outcome.args.to}`);
797
+ print(`amount ${usdz(outcome.args.amount)}`);
798
+ if (target.explorerUrl)
799
+ print(`explorer ${target.explorerUrl}/tx/${receipt.transactionHash}`);
800
+ print(`gas ${receipt.gasUsed}`);
801
+ return result;
369
802
  }
370
- /** A decimal `*USDZ` amount a mandate can pay: positive, in whole cents. */
803
+ /** A decimal `*USDZ` amount a mandate can pay: positive, to the cent. */
371
804
  export function parsePullAmount(text, option) {
372
805
  return BigInt(usdzBaseUnits(parseCents(text, option)));
373
806
  }