@metalntwx/metal-agent 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.
- package/README.md +31 -27
- package/dist/chain.js +1 -1
- package/dist/commands/mandate.js +450 -138
- package/dist/commands/pay.js +1 -1
- package/dist/help.js +16 -15
- package/dist/index.js +27 -11
- package/dist/keystore.js +4 -8
- package/dist/mcp.js +8 -8
- package/package.json +6 -6
- package/skills/metal-agent/SKILL.md +23 -16
package/dist/commands/mandate.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
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
|
|
3
|
-
//
|
|
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
|
|
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 {
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
|
-
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, publicMessage, pullCall, readBoundAgentKey, readCheck, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
|
|
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";
|
|
12
13
|
import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
|
|
13
|
-
import { BaseError, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, 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";
|
|
14
15
|
import { rpcClient } from "../chain.js";
|
|
15
|
-
import {
|
|
16
|
+
import { fund } from "./fund.js";
|
|
17
|
+
import { exists, mandateKeyPath, pendingPullsPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
|
|
16
18
|
export async function loadMandate(credentialPath) {
|
|
17
19
|
const credential = parseCredential(JSON.parse(await readFile(credentialPath, "utf8")));
|
|
18
20
|
const terms = extractTerms(credential);
|
|
@@ -60,16 +62,20 @@ export function parseCount(text, option) {
|
|
|
60
62
|
const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
|
|
61
63
|
const utc = (seconds) => new Date(Number(seconds) * 1000).toISOString();
|
|
62
64
|
const day = (seconds) => new Date(seconds * 1000).toISOString().slice(0, 10);
|
|
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;
|
|
63
70
|
function printTerms({ principal, agentKey, constraints: c, terms, descriptive }, print) {
|
|
64
|
-
// The credential's own text is shown with control, line-separator, bidi and invisible characters replaced, so it cannot start a line,
|
|
65
|
-
// reorder one or hide text; the joiners scripts and emoji need are kept.
|
|
66
71
|
if (typeof descriptive.promptSummary === "string")
|
|
67
|
-
print(`description ${descriptive.promptSummary.replace(
|
|
72
|
+
print(`description ${descriptive.promptSummary.replace(UNSHOWN, "?")}`);
|
|
68
73
|
print(`principal ${principal}`);
|
|
69
|
-
print(`
|
|
74
|
+
print(`mandate-key ${encodeAgentKey(agentKey)} (P-256)`);
|
|
70
75
|
print(`token ${c.token}`);
|
|
71
76
|
print(`per-payment-cap ${usdz(c.perPaymentCap)}`);
|
|
72
77
|
print(`budget ${usdz(c.budget)} until expiry`);
|
|
78
|
+
print(`starts ${c.recurrenceStart} (${utc(c.recurrenceStart)})`);
|
|
73
79
|
print(`expiry ${c.expiry} (${utc(c.expiry)})`);
|
|
74
80
|
if (c.maxOccurrences !== UNCAPPED_OCCURRENCES)
|
|
75
81
|
print(`max-occurrences ${c.maxOccurrences}`);
|
|
@@ -80,7 +86,7 @@ function printTerms({ principal, agentKey, constraints: c, terms, descriptive },
|
|
|
80
86
|
const object = shown.find((o) => typeof o.id === "string" && o.id.toLowerCase() === payee.account.toLowerCase());
|
|
81
87
|
const name = typeof object?.name === "string" && object.name.toLowerCase() !== payee.account.toLowerCase() ? ` ${object.name}` : "";
|
|
82
88
|
const website = typeof object?.website === "string" && /^https?:/.test(object.website) ? ` ${object.website}` : "";
|
|
83
|
-
print(`payee ${payee.account}${name}${website}`.replace(
|
|
89
|
+
print(`payee ${payee.account}${name}${website}`.replace(UNSHOWN, "?"));
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
function walletFor(publicClient, account) {
|
|
@@ -105,25 +111,27 @@ function printSent(target, receipt, print) {
|
|
|
105
111
|
}
|
|
106
112
|
/**
|
|
107
113
|
* Issues the credential a mandate is bound from, the developer signing it as
|
|
108
|
-
* its own issuer; with no payees it lets the agent pay any address within
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* 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.
|
|
114
119
|
*/
|
|
115
120
|
export async function credentialCreate(target, args, print) {
|
|
116
121
|
if (await exists(args.out))
|
|
117
122
|
throw new Error(`${args.out} already exists; pass another --out`);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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`);
|
|
121
128
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
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);
|
|
125
132
|
const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath).catch(async (error) => {
|
|
126
|
-
|
|
133
|
+
if (mandateKey.created)
|
|
134
|
+
await rm(keyPath);
|
|
127
135
|
throw error;
|
|
128
136
|
});
|
|
129
137
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -135,13 +143,13 @@ export async function credentialCreate(target, args, print) {
|
|
|
135
143
|
issuer: keys.issuer,
|
|
136
144
|
principal: args.principal,
|
|
137
145
|
principalKey: keys.principalKey,
|
|
138
|
-
agentKey:
|
|
146
|
+
agentKey: mandateKey.key.publicJwk,
|
|
139
147
|
perPaymentCap: args.cap,
|
|
140
148
|
budget: args.budget,
|
|
141
149
|
payees: args.payees.map(({ address, name, website }) => ({ address, name: name ?? address, website: website ?? `eip155:${target.network.chainId}:${address}` })),
|
|
142
150
|
recurrence: {
|
|
143
151
|
frequency: "ADHO",
|
|
144
|
-
startDate: day(now),
|
|
152
|
+
startDate: args.startsIn === undefined ? day(now) : new Date((now + args.startsIn) * 1000).toISOString().replace(".000Z", "Z"),
|
|
145
153
|
endDate: day(expiresAt),
|
|
146
154
|
...(args.maxOccurrences !== undefined && { maxOccurrences: args.maxOccurrences }),
|
|
147
155
|
},
|
|
@@ -153,18 +161,16 @@ export async function credentialCreate(target, args, print) {
|
|
|
153
161
|
}
|
|
154
162
|
catch (error) {
|
|
155
163
|
// The keys this run created name nothing yet; removing them lets the corrected command run again.
|
|
156
|
-
await Promise.all([
|
|
164
|
+
await Promise.all([mandateKey.created && rm(keyPath), created && rm(args.issuerKeyPath)]);
|
|
157
165
|
throw error;
|
|
158
166
|
}
|
|
159
167
|
await writeFile(args.out, `${JSON.stringify(credential, null, 2)}\n`, { flag: "wx" });
|
|
160
168
|
const issuerPublicPath = publicKeyPath(args.issuerKeyPath);
|
|
161
169
|
await writePublicKeyFile(issuerPublicPath, trustedIssuer(keys.issuer));
|
|
162
170
|
print(`credential ${args.out}`);
|
|
163
|
-
print(`
|
|
171
|
+
print(`mandate ${ingested.reference}`);
|
|
164
172
|
printTerms(ingested.extracted, print);
|
|
165
|
-
print(`
|
|
166
|
-
print(`gas-key-file ${gasPath}${gas.created ? " (created)" : ""}`);
|
|
167
|
-
print(`gas-key-address ${gas.address}`);
|
|
173
|
+
print(`mandate-key-file ${keyPath}${mandateKey.created ? " (created)" : ""}`);
|
|
168
174
|
print(`issuer-key-file ${args.issuerKeyPath}${created ? " (created)" : ""}`);
|
|
169
175
|
print(`issuer-public-file ${issuerPublicPath}`);
|
|
170
176
|
}
|
|
@@ -185,29 +191,45 @@ async function ingestFor(args) {
|
|
|
185
191
|
/** The one-owner account `owner` controls: its config for the factory, and the address the factory gives it. */
|
|
186
192
|
async function ownerAccount(client, factory, owner) {
|
|
187
193
|
const config = [[owner], [1n], 1n];
|
|
188
|
-
|
|
189
|
-
if (factoryCode === undefined || factoryCode === "0x")
|
|
194
|
+
if (!(await hasCode(client, factory)))
|
|
190
195
|
throw new Error(`no MetalAccountFactory at ${factory}; on a devnet run just deploy, or pass --factory`);
|
|
191
196
|
const address = await client.readContract({ address: factory, abi: metalAccountFactoryAbi, functionName: "predictAccount", args: config });
|
|
192
197
|
return { config, address };
|
|
193
198
|
}
|
|
194
|
-
/**
|
|
195
|
-
* `out` names a file to create
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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. */
|
|
198
204
|
export async function accountCreate(target, args, print) {
|
|
199
205
|
const keyPath = "keyPath" in args ? args.keyPath : args.out;
|
|
200
206
|
const created = "out" in args ? (await readOrCreateKey(args.out)).created : false;
|
|
201
207
|
const owner = await readKey(keyPath);
|
|
202
208
|
const client = rpcClient(target);
|
|
203
|
-
const { address } = await ownerAccount(client, args.accountFactory, owner.address);
|
|
204
|
-
const code = await client.getCode({ address });
|
|
209
|
+
const { config, address } = await ownerAccount(client, args.accountFactory, owner.address);
|
|
205
210
|
print(`account ${address}`);
|
|
206
|
-
print(`deployed ${code === undefined || code === "0x" ? "no, the first bind creates it" : "yes"}`);
|
|
207
211
|
print(`owner ${owner.address}`);
|
|
208
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);
|
|
227
|
+
}
|
|
228
|
+
async function hasCode(client, address) {
|
|
229
|
+
const code = await client.getCode({ address });
|
|
230
|
+
return code !== undefined && code !== "0x";
|
|
209
231
|
}
|
|
210
|
-
async function sendOwnerCall(client, args, call
|
|
232
|
+
async function sendOwnerCall(client, args, call) {
|
|
211
233
|
const account = (Array.isArray(call) ? call[0] : call)?.to;
|
|
212
234
|
if (account === undefined)
|
|
213
235
|
throw new Error("no account call to send");
|
|
@@ -216,18 +238,11 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
|
|
|
216
238
|
signers.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()));
|
|
217
239
|
if (new Set(signers.map(s => s.address.toLowerCase())).size !== signers.length)
|
|
218
240
|
throw new Error("duplicate account signer");
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
throw new Error(`${account} 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`);
|
|
225
|
-
}
|
|
226
|
-
if (code === undefined || code === "0x") {
|
|
227
|
-
const { config, address } = await ownerAccount(client, args.accountFactory, sponsor.address);
|
|
228
|
-
if (address.toLowerCase() !== account.toLowerCase())
|
|
229
|
-
throw new Error(`${account} is not the account of ${sponsor.address}; it has no code, and only its owner's first bind creates it`);
|
|
230
|
-
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`);
|
|
231
246
|
}
|
|
232
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 })))]));
|
|
233
248
|
return send(client, sponsor, authorized);
|
|
@@ -235,7 +250,7 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
|
|
|
235
250
|
export async function mandateBind(target, args, print) {
|
|
236
251
|
const { ingested } = await ingestFor(args);
|
|
237
252
|
const publicClient = rpcClient(target);
|
|
238
|
-
const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested)
|
|
253
|
+
const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested));
|
|
239
254
|
print(`bound ${ingested.reference}`);
|
|
240
255
|
printSent(target, receipt, print);
|
|
241
256
|
}
|
|
@@ -297,11 +312,10 @@ export async function mandateRevoke(target, args, print) {
|
|
|
297
312
|
export async function mandateStatus(target, credentialPath, accountFactory, print) {
|
|
298
313
|
const { terms, reference } = await loadMandate(credentialPath);
|
|
299
314
|
const publicClient = rpcClient(target);
|
|
300
|
-
print(`
|
|
315
|
+
print(`mandate ${reference}`);
|
|
301
316
|
printTerms(terms, print);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
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");
|
|
305
319
|
print("bound no");
|
|
306
320
|
return;
|
|
307
321
|
}
|
|
@@ -319,7 +333,7 @@ export async function mandateStatus(target, credentialPath, accountFactory, prin
|
|
|
319
333
|
/**
|
|
320
334
|
* Verifies the mandate and its payments from the chain alone: the binding on
|
|
321
335
|
* the principal's account, the credential against it and under its issuer,
|
|
322
|
-
* and each payment and refusal as the
|
|
336
|
+
* and each payment and refusal as the mandate key's own per-payment mandate,
|
|
323
337
|
* rebuilt from the transaction that carried it. Any party holding the
|
|
324
338
|
* credential can run it; Metal is not asked.
|
|
325
339
|
*/
|
|
@@ -332,36 +346,47 @@ export async function mandateVerify(target, args, print) {
|
|
|
332
346
|
const v = args.tx === undefined
|
|
333
347
|
? await verifyMandate(publicClient, opts)
|
|
334
348
|
: await verifyMandatePayment(publicClient, { ...opts, tx: args.tx, ...(args.signedOffer === undefined ? {} : { checkoutMandate: args.signedOffer }) });
|
|
335
|
-
print(`
|
|
349
|
+
print(`mandate ${reference}`);
|
|
336
350
|
for (const step of v.steps) {
|
|
337
351
|
print(`step ${step.step} ${step.outcome}: ${step.title}`);
|
|
338
352
|
print(` ${step.detail}`);
|
|
339
353
|
}
|
|
340
354
|
for (const p of v.payments) {
|
|
341
355
|
const what = p.kind === "payment" ? "payment" : `refusal (${p.reason === undefined || p.reason === "None" ? "?" : REFUSALS[p.reason].reason})`;
|
|
342
|
-
|
|
343
|
-
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(", ")}`}`);
|
|
344
357
|
}
|
|
345
358
|
print(`verified ${v.ok ? "yes" : "no"}`);
|
|
346
359
|
return v.ok ? "verified" : "failed";
|
|
347
360
|
}
|
|
348
|
-
/**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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`);
|
|
365
390
|
}
|
|
366
391
|
/** A revert's Mandate Binding error name (`NonceAlreadyUsed`, `BadAgentSignature`, …), or the node's message. */
|
|
367
392
|
function revertName(error) {
|
|
@@ -379,81 +404,355 @@ function revertName(error) {
|
|
|
379
404
|
return error.shortMessage;
|
|
380
405
|
}
|
|
381
406
|
/**
|
|
382
|
-
* A direct payment under the mandate, with no seller: the agent signs it and
|
|
383
|
-
* the `--key` account submits it to `pull` on the
|
|
384
|
-
* pays the gas. One the mandate refuses when checked
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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.
|
|
390
418
|
*/
|
|
391
419
|
export async function mandatePull(target, args, print) {
|
|
392
420
|
const mandate = await loadMandate(args.credentialPath);
|
|
393
|
-
const signDigest = await mandateAgentSigner(mandate, args.
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
+
});
|
|
399
436
|
const { terms, reference } = mandate;
|
|
400
437
|
const publicClient = rpcClient(target);
|
|
401
438
|
// A mandate not yet bound, or before its dates, can stop refusing; these are
|
|
402
439
|
// checked before the agent signs, so a pull reported unsent can never pay later.
|
|
403
440
|
const [bound, block, gas] = await Promise.all([
|
|
404
|
-
publicClient
|
|
441
|
+
hasCode(publicClient, terms.principal).then((deployed) => (deployed ? readBoundAgentKey(publicClient, terms.principal, reference) : undefined)),
|
|
405
442
|
publicClient.getBlock(),
|
|
406
|
-
publicClient.getBalance({ address: sender.address }),
|
|
443
|
+
sender === undefined ? undefined : publicClient.getBalance({ address: sender.address }),
|
|
407
444
|
]);
|
|
408
445
|
if (bound === undefined)
|
|
409
446
|
throw new Error(`the mandate ${reference} is not bound on ${terms.principal}; the owner binds it with mandate bind`);
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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 = {
|
|
426
482
|
scheme: "exact",
|
|
427
|
-
network: `eip155:${chainId}`,
|
|
483
|
+
network: `eip155:${target.network.chainId}`,
|
|
428
484
|
amount: args.amount.toString(),
|
|
429
485
|
asset: terms.constraints.token,
|
|
430
486
|
payTo: args.to,
|
|
431
487
|
maxTimeoutSeconds: MANDATE_MAX_VALIDITY_SECONDS,
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
|
|
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;
|
|
554
|
+
});
|
|
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;
|
|
435
581
|
});
|
|
436
|
-
|
|
582
|
+
if (text === undefined)
|
|
583
|
+
return undefined;
|
|
437
584
|
try {
|
|
438
|
-
|
|
439
|
-
|
|
585
|
+
return JSON.parse(text, (key, value) => (BIGINTS.includes(key) && typeof value === "string" ? BigInt(value) : value));
|
|
586
|
+
}
|
|
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 });
|
|
440
627
|
}
|
|
441
628
|
catch (error) {
|
|
442
629
|
if (lostInTransport(error))
|
|
443
630
|
throw new Error(`the pull could not be checked: ${publicMessage(error)}`);
|
|
444
631
|
throw new Error(`Mandate Binding rejects the pull and records nothing: ${revertName(error)}`);
|
|
445
632
|
}
|
|
446
|
-
const reason = await readCheck(publicClient, terms.principal, signed.pull, signed.fields);
|
|
447
|
-
if (reason !== "None" && !args.recordRefusal)
|
|
448
|
-
return printUnsent(reason, print);
|
|
449
|
-
const hash = await sendMandateCall(publicClient, walletFor(publicClient, sender), call);
|
|
450
|
-
// Printed before the wait: a pull whose receipt never arrives is still found by its transaction.
|
|
451
|
-
print(`transaction ${hash}`);
|
|
452
|
-
const receipt = await confirm(publicClient, hash).catch((error) => {
|
|
453
|
-
throw new Error(`${error instanceof Error ? error.message : String(error)}; the pull was sent in transaction ${hash}: check it with metal-agent status ${hash} before pulling again`);
|
|
454
|
-
});
|
|
455
|
-
return printPull(target, terms, slice(signed.pull.nonce, 12), receipt, print);
|
|
456
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)"]);
|
|
457
756
|
/** Whether the request failed on its way, so the node may or may not have acted on it. */
|
|
458
757
|
function lostInTransport(error) {
|
|
459
758
|
return error instanceof BaseError && error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError) !== null;
|
|
@@ -464,29 +763,42 @@ function printUnsent(reason, print) {
|
|
|
464
763
|
print("recorded no");
|
|
465
764
|
return "refused";
|
|
466
765
|
}
|
|
467
|
-
function
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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");
|
|
471
786
|
print(`spent ${usdz(outcome.args.spent)} of ${usdz(terms.constraints.budget)}`);
|
|
472
787
|
}
|
|
473
|
-
else
|
|
788
|
+
else {
|
|
474
789
|
print("outcome refused");
|
|
475
790
|
const reason = reasonName(outcome.args.reason);
|
|
476
791
|
if (reason === "None")
|
|
477
792
|
throw new Error(`transaction ${receipt.transactionHash} records a refusal with no reason`);
|
|
478
793
|
print(`reason ${describeRefusal(reason)}`);
|
|
479
794
|
}
|
|
480
|
-
else {
|
|
481
|
-
throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
|
|
482
|
-
}
|
|
483
795
|
print(`payer ${terms.principal}`);
|
|
484
796
|
print(`payee ${outcome.args.to}`);
|
|
485
797
|
print(`amount ${usdz(outcome.args.amount)}`);
|
|
486
798
|
if (target.explorerUrl)
|
|
487
799
|
print(`explorer ${target.explorerUrl}/tx/${receipt.transactionHash}`);
|
|
488
800
|
print(`gas ${receipt.gasUsed}`);
|
|
489
|
-
return
|
|
801
|
+
return result;
|
|
490
802
|
}
|
|
491
803
|
/** A decimal `*USDZ` amount a mandate can pay: positive, to the cent. */
|
|
492
804
|
export function parsePullAmount(text, option) {
|