@metalntwx/metal-agent 0.3.0 → 0.3.2
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 +41 -19
- package/dist/commands/fund.js +28 -14
- package/dist/commands/key.js +2 -2
- package/dist/commands/mandate.js +221 -59
- package/dist/commands/pay.js +85 -12
- package/dist/help.js +9 -9
- package/dist/index.js +14 -4
- package/dist/keystore.js +28 -12
- package/dist/mcp.js +269 -27
- package/package.json +2 -2
- package/skills/metal-agent/SKILL.md +81 -46
package/dist/commands/mandate.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
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 { randomUUID } from "node:crypto";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
10
|
import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
11
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,
|
|
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, readCheck, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
|
|
13
13
|
import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
|
|
14
|
-
import { BaseError, erc20Abi,
|
|
14
|
+
import { BaseError, erc20Abi, formatLog, parseAbi, parseAbiItem, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, isHash, numberToHex, parseEventLogs, slice, } from "viem";
|
|
15
15
|
import { rpcClient } from "../chain.js";
|
|
16
16
|
import { fund } from "./fund.js";
|
|
17
17
|
import { exists, mandateKeyPath, pendingPullsPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
|
|
@@ -199,14 +199,20 @@ async function ownerAccount(client, factory, owner) {
|
|
|
199
199
|
/** Creates the owner's one-owner MetalAccount on chain, writing the owner's
|
|
200
200
|
* key first when `out` names a file to create. The factory gives each owner
|
|
201
201
|
* one address, so an account that exists is named and nothing is sent. An
|
|
202
|
-
* owner with
|
|
202
|
+
* owner with too little gas is funded first, from the faucet on testnet. Mixed-key
|
|
203
203
|
* and weighted accounts use the same factory through the SDK. */
|
|
204
204
|
export async function accountCreate(target, args, print) {
|
|
205
205
|
const keyPath = "keyPath" in args ? args.keyPath : args.out;
|
|
206
206
|
const created = "out" in args ? (await readOrCreateKey(args.out)).created : false;
|
|
207
207
|
const owner = await readKey(keyPath);
|
|
208
|
+
// The owner key is written before anything is read from the chain: a failure names it, and the same command continues.
|
|
209
|
+
await deployAccount(target, args.accountFactory, keyPath, created, owner, print).catch((error) => {
|
|
210
|
+
throw new Error(`${error instanceof BaseError || !(error instanceof Error) ? publicMessage(error) : error.message}. The owner key is in ${keyPath}; the same command continues from it`);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async function deployAccount(target, accountFactory, keyPath, created, owner, print) {
|
|
208
214
|
const client = rpcClient(target);
|
|
209
|
-
const { config, address } = await ownerAccount(client,
|
|
215
|
+
const { config, address } = await ownerAccount(client, accountFactory, owner.address);
|
|
210
216
|
print(`account ${address}`);
|
|
211
217
|
print(`owner ${owner.address}`);
|
|
212
218
|
print(`keyfile ${keyPath}${created ? " (created)" : ""}`);
|
|
@@ -214,14 +220,30 @@ export async function accountCreate(target, args, print) {
|
|
|
214
220
|
print("deployed yes");
|
|
215
221
|
return;
|
|
216
222
|
}
|
|
217
|
-
|
|
223
|
+
const call = { to: accountFactory, data: encodeFunctionData({ abi: metalAccountFactoryAbi, functionName: "createAccount", args: config }), gas: 600000n, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS };
|
|
224
|
+
const needed = call.gas * FEE_PER_GAS;
|
|
225
|
+
if ((await client.getBalance({ address: owner.address })) < needed) {
|
|
218
226
|
// Only the grant's summary: its transaction lines would read as the account's.
|
|
219
227
|
const funding = [];
|
|
220
|
-
|
|
228
|
+
// `key import` loads the agent's key, which account create never reads.
|
|
229
|
+
const refused = await fund(target, owner.address, undefined, (line) => funding.push(line), fetch, false).then(() => undefined, (error) => error);
|
|
221
230
|
if (funding[0] !== undefined)
|
|
222
231
|
print(funding[0]);
|
|
232
|
+
// A grant the faucet could not confirm can have reached the owner all the same.
|
|
233
|
+
if (refused !== undefined) {
|
|
234
|
+
const held = await client.getBalance({ address: owner.address });
|
|
235
|
+
if (held < needed)
|
|
236
|
+
throw new Error(`the owner ${owner.address} holds ${usdz(held)}, and deploying the account needs ${usdz(needed)}, so it is not deployed: ${refused instanceof Error ? refused.message : String(refused)}`);
|
|
237
|
+
}
|
|
223
238
|
}
|
|
224
|
-
const
|
|
239
|
+
const hash = await sendMandateCall(client, walletFor(client, owner), call).catch((error) => {
|
|
240
|
+
throw new Error(`the account is not deployed: ${publicMessage(error)}`);
|
|
241
|
+
});
|
|
242
|
+
const receipt = await client.waitForTransactionReceipt({ hash, pollingInterval: 250 }).catch((error) => {
|
|
243
|
+
throw new Error(`the account's deployment was sent in transaction ${hash}, and its receipt could not be read: ${publicMessage(error)}; the same command reports the account once it is deployed`);
|
|
244
|
+
});
|
|
245
|
+
if (receipt.status !== "success")
|
|
246
|
+
throw new Error(`the account is not deployed: transaction ${hash} reverted`);
|
|
225
247
|
print("deployed yes");
|
|
226
248
|
printSent(target, receipt, print);
|
|
227
249
|
}
|
|
@@ -244,7 +266,9 @@ async function sendOwnerCall(client, args, call) {
|
|
|
244
266
|
throw new Error(`${account} has no account: create it with metal-agent account create`);
|
|
245
267
|
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`);
|
|
246
268
|
}
|
|
247
|
-
|
|
269
|
+
// The quorum's signature is good for fifteen minutes: long enough to send, not to hold.
|
|
270
|
+
const validUntil = BigInt(Math.floor(Date.now() / 1000)) + 900n;
|
|
271
|
+
const authorized = await authorizeMandateCall(client, args.accountFactory, call, validUntil, async (digest) => encodeAbiParameters(parseAbiParameters("bytes[], bytes[]"), [signers.map(s => s.address), await Promise.all(signers.map(s => s.sign({ hash: digest })))]));
|
|
248
272
|
return send(client, sponsor, authorized);
|
|
249
273
|
}
|
|
250
274
|
export async function mandateBind(target, args, print) {
|
|
@@ -317,18 +341,19 @@ export async function mandateStatus(target, credentialPath, accountFactory, prin
|
|
|
317
341
|
if (!(await hasCode(publicClient, terms.principal))) {
|
|
318
342
|
print("account-type none; create the account before binding: metal-agent account create for one owner");
|
|
319
343
|
print("bound no");
|
|
320
|
-
return;
|
|
344
|
+
return "unbound";
|
|
321
345
|
}
|
|
322
346
|
await assertMetalAccount(publicClient, terms.principal, accountFactory);
|
|
323
347
|
print("account-type MetalAccount");
|
|
324
348
|
const bound = (await publicClient.readContract({ address: terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [reference] })).principal.toLowerCase() === terms.principal.toLowerCase();
|
|
325
349
|
print(`bound ${bound ? "yes" : "no"}`);
|
|
326
350
|
if (!bound)
|
|
327
|
-
return;
|
|
351
|
+
return "unbound";
|
|
328
352
|
const state = await readMandateState(publicClient, terms.principal, reference);
|
|
329
353
|
print(`revoked ${state.revoked ? "yes" : "no"}`);
|
|
330
354
|
print(`spent ${usdz(state.spent)} of ${usdz(terms.constraints.budget)}`);
|
|
331
355
|
print(`payments ${state.occurrences}`);
|
|
356
|
+
return state.revoked ? "revoked" : "live";
|
|
332
357
|
}
|
|
333
358
|
/**
|
|
334
359
|
* Verifies the mandate and its payments from the chain alone: the binding on
|
|
@@ -411,10 +436,12 @@ function revertName(error) {
|
|
|
411
436
|
* record the refusal on chain.
|
|
412
437
|
*
|
|
413
438
|
* 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.
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
439
|
+
* facilitator answers, so its outcome is read from the chain alone. Each is
|
|
440
|
+
* kept under `~/.metal-agent/pulls/<mandate>/`, per payment id when one is
|
|
441
|
+
* given and per payee and amount otherwise, and the same command sends the
|
|
442
|
+
* pull with no outcome yet again, which the account pays at most once; once
|
|
443
|
+
* it can no longer land, the command signs a new one. Under a payment id, a
|
|
444
|
+
* payout that was paid or held is reported again and nothing is sent.
|
|
418
445
|
*/
|
|
419
446
|
export async function mandatePull(target, args, print) {
|
|
420
447
|
const mandate = await loadMandate(args.credentialPath);
|
|
@@ -422,10 +449,6 @@ export async function mandatePull(target, args, print) {
|
|
|
422
449
|
const keyPath = args.keyPath;
|
|
423
450
|
if (args.recordRefusal && keyPath === undefined)
|
|
424
451
|
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
452
|
const sender = keyPath === undefined
|
|
430
453
|
? undefined
|
|
431
454
|
: await readKey(keyPath).catch((error) => {
|
|
@@ -438,44 +461,89 @@ export async function mandatePull(target, args, print) {
|
|
|
438
461
|
// A mandate not yet bound, or before its dates, can stop refusing; these are
|
|
439
462
|
// checked before the agent signs, so a pull reported unsent can never pay later.
|
|
440
463
|
const [bound, block, gas] = await Promise.all([
|
|
441
|
-
hasCode(publicClient, terms.principal).then((deployed) => (deployed ?
|
|
464
|
+
hasCode(publicClient, terms.principal).then((deployed) => (deployed ? publicClient.readContract({ address: terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [reference] }) : undefined)),
|
|
442
465
|
publicClient.getBlock(),
|
|
443
466
|
sender === undefined ? undefined : publicClient.getBalance({ address: sender.address }),
|
|
444
467
|
]);
|
|
445
468
|
if (bound === undefined)
|
|
469
|
+
throw new Error(`the mandate's account ${terms.principal} does not exist: the owner creates it with metal-agent account create, then binds the mandate with mandate bind`);
|
|
470
|
+
if (!isAddressEqual(bound.principal, terms.principal))
|
|
446
471
|
throw new Error(`the mandate ${reference} is not bound on ${terms.principal}; the owner binds it with mandate bind`);
|
|
447
472
|
if (sender !== undefined && gas === 0n)
|
|
448
473
|
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
|
-
|
|
474
|
+
// A hash names a payment id's directory: any text is one path segment, and ids that differ only in case stay apart.
|
|
475
|
+
const keptPath = join(pendingPullsPath(reference), args.paymentId === undefined ? `${args.to.toLowerCase()}-${args.amount}` : `payment-${createHash("sha256").update(args.paymentId).digest("hex")}`);
|
|
450
476
|
const end = await chainEnd(keptPath);
|
|
477
|
+
if (end !== undefined) {
|
|
478
|
+
const { pull } = end;
|
|
479
|
+
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)) {
|
|
480
|
+
if (args.paymentId === undefined || slice(pull.nonce, 0, 12) !== slice(reference, 0, 12)) {
|
|
481
|
+
throw new Error(`${keptPath} holds a pull for another payment than this mandate's ${usdz(args.amount)} to ${args.to}`);
|
|
482
|
+
}
|
|
483
|
+
const recorded = join(keptPath, `${pull.nonce}.done`);
|
|
484
|
+
const ended = (await exists(recorded)) ? (await readReported(recorded)).outcome : undefined;
|
|
485
|
+
// A payout the account refused paid nothing: the payment id is sent with these values, as a new pull after it.
|
|
486
|
+
if (ended !== "refused") {
|
|
487
|
+
const named = `the payout under payment id ${JSON.stringify(args.paymentId)} is ${usdz(pull.value)} to ${getAddress(pull.to)}`;
|
|
488
|
+
if (ended !== undefined)
|
|
489
|
+
throw new Error(`${named}: send it again with those values; another payment id is another payment`);
|
|
490
|
+
// One with no outcome yet can still land: another id would pay a second time.
|
|
491
|
+
throw new Error(`${named}, signed to land until ${utc(pull.validBefore)}: send it again with those values, to send it or learn how it ended; another payment id is another payment`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// What a run prints from the outcome on, kept with the outcome for a payment id's later runs.
|
|
496
|
+
const shown = [];
|
|
497
|
+
const show = (line) => {
|
|
498
|
+
shown.push(line);
|
|
499
|
+
print(line);
|
|
500
|
+
};
|
|
451
501
|
let attempt;
|
|
452
502
|
// The pull a new one follows in the chain; the chain's first when there is none.
|
|
453
503
|
let follows;
|
|
454
504
|
let checked;
|
|
455
|
-
|
|
456
|
-
|
|
505
|
+
const done = end === undefined ? undefined : join(keptPath, `${end.pull.nonce}.done`);
|
|
506
|
+
if (end !== undefined && done !== undefined && (await exists(done))) {
|
|
507
|
+
// A payment id's payout that moved money is that payment: it is reported again, and nothing is sent.
|
|
508
|
+
const reported = args.paymentId === undefined ? undefined : await readReported(done);
|
|
509
|
+
if (reported?.outcome === "paid" || reported?.outcome === "held") {
|
|
510
|
+
// The token records the authorization the pull spent; a chain reset since holds none.
|
|
511
|
+
const spent = await publicClient.readContract({ address: end.pull.token, abi: AUTHORIZATION_STATE, functionName: "authorizationState", args: [terms.principal, end.pull.nonce] });
|
|
512
|
+
if (!spent)
|
|
513
|
+
throw new Error(`payment id ${JSON.stringify(args.paymentId)} is recorded as ${reported.outcome} in a transaction this chain does not hold, as after a chain reset; nothing was sent`);
|
|
514
|
+
print(`already ${reported.outcome}, nothing sent`);
|
|
515
|
+
// The spend is the mandate's now, which later payments have added to.
|
|
516
|
+
for (const line of reported.lines)
|
|
517
|
+
print(line.startsWith("spent ") ? `spent ${usdz(bound.spent)} of ${usdz(terms.constraints.budget)}` : line);
|
|
518
|
+
return reported.outcome;
|
|
519
|
+
}
|
|
520
|
+
// Its outcome is reported: this is a new payment, or under a payment id one more try at it.
|
|
457
521
|
follows = end.pull.nonce;
|
|
458
522
|
}
|
|
459
523
|
else if (end !== undefined) {
|
|
460
524
|
attempt = end;
|
|
461
|
-
const
|
|
462
|
-
if (
|
|
463
|
-
throw
|
|
464
|
-
}
|
|
465
|
-
const found = await outcomeOnChain(publicClient, terms, attempt);
|
|
525
|
+
const found = await outcomeOnChain(publicClient, terms, reference, attempt);
|
|
526
|
+
if (found.kind === "undecoded")
|
|
527
|
+
throw undecodedPull(terms, found);
|
|
466
528
|
if (found.kind === "landed")
|
|
467
|
-
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print)
|
|
529
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
|
|
468
530
|
if (found.kind === "open" && found.landed)
|
|
469
|
-
return printPending(print, "it has landed, and its log cannot be read yet", attempt,
|
|
531
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, args);
|
|
470
532
|
// One that can never land is followed by a new pull, signed and checked below.
|
|
471
533
|
if (found.kind === "never") {
|
|
472
534
|
follows = attempt.pull.nonce;
|
|
473
535
|
attempt = undefined;
|
|
474
536
|
}
|
|
475
537
|
}
|
|
538
|
+
const facilitatorProtocol = URL.canParse(target.facilitatorUrl) ? new URL(target.facilitatorUrl).protocol : "";
|
|
539
|
+
if (keyPath === undefined && facilitatorProtocol !== "http:" && facilitatorProtocol !== "https:") {
|
|
540
|
+
const sentNothing = `the facilitator URL ${target.facilitatorUrl} is not an http or https URL, so`;
|
|
541
|
+
throw new Error(attempt === undefined ? `${sentNothing} the pull was not sent` : `${sentNothing} this run sent nothing; ${keptBefore(attempt)}`);
|
|
542
|
+
}
|
|
476
543
|
if (attempt === undefined) {
|
|
544
|
+
// Mandate Binding names the mandate's state before its dates; one that has not started cannot have expired.
|
|
477
545
|
if (block.timestamp < terms.constraints.recurrenceStart && !args.recordRefusal)
|
|
478
|
-
return printUnsent("OutsideRecurrence", print);
|
|
546
|
+
return printUnsent(bound.revoked ? "Revoked" : "OutsideRecurrence", print);
|
|
479
547
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
480
548
|
// What a seller's offer would be; the facilitator settles the pull against it.
|
|
481
549
|
const requirements = {
|
|
@@ -505,7 +573,7 @@ export async function mandatePull(target, args, print) {
|
|
|
505
573
|
return printUnsent(reason, print);
|
|
506
574
|
await simulatePull(publicClient, terms, fresh, sender);
|
|
507
575
|
checked = fresh.pull.nonce;
|
|
508
|
-
// Kept before it leaves: from here on it can land, and the same
|
|
576
|
+
// Kept before it leaves: from here on it can land, and the same pull sends it again.
|
|
509
577
|
// A run that kept one first wins, and this run sends that one.
|
|
510
578
|
attempt = await keepFirst(join(keptPath, follows === undefined ? "first.json" : `${follows}.next.json`), fresh);
|
|
511
579
|
}
|
|
@@ -513,47 +581,97 @@ export async function mandatePull(target, args, print) {
|
|
|
513
581
|
const rejected = await simulatePull(publicClient, terms, attempt, sender).then(() => undefined, (error) => error);
|
|
514
582
|
if (rejected !== undefined) {
|
|
515
583
|
// A pull another run kept, or one kept before, can have landed since it was read.
|
|
516
|
-
const found = await outcomeOnChain(publicClient, terms, attempt);
|
|
584
|
+
const found = await outcomeOnChain(publicClient, terms, reference, attempt);
|
|
585
|
+
if (found.kind === "undecoded")
|
|
586
|
+
throw undecodedPull(terms, found);
|
|
517
587
|
if (found.kind === "landed") {
|
|
518
|
-
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print)
|
|
588
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
|
|
519
589
|
}
|
|
520
590
|
if (found.kind === "open" && found.landed)
|
|
521
|
-
return printPending(print, "it has landed, and its log cannot be read yet", attempt,
|
|
522
|
-
throw rejected;
|
|
591
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, args);
|
|
592
|
+
throw new Error(`${rejected instanceof Error ? rejected.message : String(rejected)}; ${keptBefore(attempt)}`);
|
|
523
593
|
}
|
|
524
594
|
}
|
|
525
595
|
const call = pullCall(terms.principal, attempt.pull, attempt.fields);
|
|
526
596
|
const sent = sender === undefined ? await sendToFacilitator(target, terms, attempt) : await sendFromKey(publicClient, sender, call);
|
|
597
|
+
// A pull kept before this run may have left then, and can land whatever this run's send meets.
|
|
598
|
+
const before = attempt.pull.nonce === checked ? undefined : keptBefore(attempt);
|
|
527
599
|
if (sent.kind === "unsent")
|
|
528
|
-
throw new Error(`the pull was not sent: ${sent.cause}; it is kept
|
|
529
|
-
const found = await outcomeOnChain(publicClient, terms, attempt, sent.transaction);
|
|
600
|
+
throw new Error(before === undefined ? `the pull was not sent: ${sent.cause}; it is kept ${keptAs(args)}, and the same pull sends it` : `this run did not send the pull: ${sent.cause}; ${before}`);
|
|
601
|
+
const found = await outcomeOnChain(publicClient, terms, reference, attempt, sent.kind === "sent" ? sent.transaction : undefined);
|
|
602
|
+
if (found.kind === "undecoded")
|
|
603
|
+
throw undecodedPull(terms, found);
|
|
530
604
|
if (found.kind === "landed") {
|
|
531
|
-
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print)
|
|
605
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
|
|
532
606
|
}
|
|
533
607
|
if (found.kind === "never") {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return settle(keptPath, attempt, "refused");
|
|
608
|
+
show("outcome refused, not paid");
|
|
609
|
+
show(`reason ${describeRefusal(found.reason)}`);
|
|
610
|
+
return settle(keptPath, attempt, "refused", shown, args, print);
|
|
537
611
|
}
|
|
538
612
|
// A transaction whose receipt was read and does not carry this pull is not named as its.
|
|
539
|
-
if (sent.transaction !== undefined && found.unreadReceipt)
|
|
613
|
+
if (sent.kind === "sent" && sent.transaction !== undefined && found.unreadReceipt)
|
|
540
614
|
print(`transaction ${sent.transaction}`);
|
|
541
615
|
if (found.landed)
|
|
542
|
-
return printPending(print, "it has landed, and its log cannot be read yet", attempt,
|
|
543
|
-
|
|
616
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, args);
|
|
617
|
+
// A refusal that names no transaction sent none, and the chain read shows the pull not on it: waiting changes nothing for a pull no earlier run kept.
|
|
618
|
+
if (sent.kind === "refused" && found.unread === undefined) {
|
|
619
|
+
throw new Error(before === undefined
|
|
620
|
+
? `the pull was not sent: ${sent.cause}; it is kept ${keptAs(args)}: the same pull sends it again, and it is refused again until what the reason names changes`
|
|
621
|
+
: `this run did not send the pull: ${sent.cause}; ${before}`);
|
|
622
|
+
}
|
|
623
|
+
const why = sent.kind === "refused" ? sent.cause : sent.why;
|
|
624
|
+
return printPending(print, found.unread === undefined ? why : `${why}; the chain could not be read: ${found.unread}`, attempt, args);
|
|
544
625
|
}
|
|
626
|
+
/** How a payout is kept, as its retry names it: by its payment id, or else by its amount and payee. */
|
|
627
|
+
function keptAs({ paymentId, amount, to }) {
|
|
628
|
+
return paymentId === undefined ? `for ${usdz(amount)} to ${to}` : `under payment id ${JSON.stringify(paymentId)}`;
|
|
629
|
+
}
|
|
630
|
+
/** What a run that sends nothing says of a pull kept before it, which may have left then and can land until its window ends. */
|
|
631
|
+
function keptBefore(kept) {
|
|
632
|
+
return `the pull kept before may have been sent and can land until ${utc(kept.pull.validBefore)}; the same pull sends it again`;
|
|
633
|
+
}
|
|
634
|
+
const PULL_OUTCOMES = ["paid", "refused", "held", "pending"];
|
|
545
635
|
/**
|
|
546
|
-
* Marks a kept pull's outcome reported once it is printed,
|
|
547
|
-
* command makes a new payment next
|
|
636
|
+
* Marks a kept pull's outcome reported once it is printed, with the outcome
|
|
637
|
+
* and its lines, so the same command makes a new payment next, or under a
|
|
638
|
+
* payment id reports that one again; a run killed between the two leaves it
|
|
548
639
|
* for the next run to report.
|
|
549
640
|
*/
|
|
550
|
-
async function settle(keptPath, kept, outcome) {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
641
|
+
async function settle(keptPath, kept, outcome, lines, args, print) {
|
|
642
|
+
// The outcome is the chain's: a record that cannot be written leaves the next run to read it there again.
|
|
643
|
+
await record(join(keptPath, `${kept.pull.nonce}.done`), { outcome, lines }).catch((error) => {
|
|
644
|
+
print(`kept ${keptAs(args)}: the outcome was not recorded (${error instanceof Error ? error.message : String(error)}); the same pull reports it again`);
|
|
554
645
|
});
|
|
555
646
|
return outcome;
|
|
556
647
|
}
|
|
648
|
+
/** Writes the record whole beside `path` and links it into place, as a pull is kept, so no run reads a part-written record. */
|
|
649
|
+
async function record(path, outcome) {
|
|
650
|
+
const draft = `${path}.${randomUUID()}.tmp`;
|
|
651
|
+
try {
|
|
652
|
+
await writeFile(draft, `${JSON.stringify(outcome)}\n`, { mode: 0o600 });
|
|
653
|
+
await link(draft, path).catch((error) => {
|
|
654
|
+
if (error.code !== "EEXIST")
|
|
655
|
+
throw error;
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
await rm(draft, { force: true });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/** The outcome `settle` recorded at `path`, and the lines it printed. */
|
|
663
|
+
async function readReported(path) {
|
|
664
|
+
const text = await readFile(path, "utf8");
|
|
665
|
+
try {
|
|
666
|
+
const record = JSON.parse(text);
|
|
667
|
+
if (!PULL_OUTCOMES.includes(record.outcome) || !Array.isArray(record.lines))
|
|
668
|
+
throw new Error("not an outcome record");
|
|
669
|
+
return record;
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
throw new Error(`${path} cannot be read: ${error instanceof Error ? error.message : String(error)}; it records how this payment id's payout ended: tell the user; deleting it lets the same pull report the payout from the chain`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
557
675
|
/**
|
|
558
676
|
* The last pull in a payee and amount's chain: its first, then each pull kept
|
|
559
677
|
* after one that can never land or whose outcome is reported. Pulls are only
|
|
@@ -565,11 +683,11 @@ async function chainEnd(keptPath) {
|
|
|
565
683
|
kept = next;
|
|
566
684
|
return kept;
|
|
567
685
|
}
|
|
568
|
-
function printPending(print, reason, kept,
|
|
686
|
+
function printPending(print, reason, kept, args) {
|
|
569
687
|
print("outcome pending");
|
|
570
688
|
print(`reason ${reason.replace(UNSHOWN, "?").slice(0, 300)}`);
|
|
571
689
|
print(`can-land-until ${utc(kept.pull.validBefore)}`);
|
|
572
|
-
print(`kept ${
|
|
690
|
+
print(`kept ${keptAs(args)}: the same pull sends it again, which pays at most once`);
|
|
573
691
|
return "pending";
|
|
574
692
|
}
|
|
575
693
|
const BIGINTS = ["value", "validAfter", "validBefore", "fromBlock"];
|
|
@@ -585,7 +703,7 @@ async function readKept(path) {
|
|
|
585
703
|
return JSON.parse(text, (key, value) => (BIGINTS.includes(key) && typeof value === "string" ? BigInt(value) : value));
|
|
586
704
|
}
|
|
587
705
|
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`);
|
|
706
|
+
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, about when the file was last modified: tell the user; once that has passed, deleting it lets the same pull sign a new one`);
|
|
589
707
|
}
|
|
590
708
|
}
|
|
591
709
|
/**
|
|
@@ -633,6 +751,8 @@ async function simulatePull(publicClient, terms, kept, sender) {
|
|
|
633
751
|
}
|
|
634
752
|
/** Above the facilitator's own 60 s wait for a receipt, so its answer is not cut short. */
|
|
635
753
|
const FACILITATOR_TIMEOUT_MS = 120_000;
|
|
754
|
+
/** The facilitator's reasons for an answer with no transaction that leave open whether the pull, or a payment, is on its way. */
|
|
755
|
+
const IN_DOUBT = new Set(["settlement_in_doubt", "payment_in_flight"]);
|
|
636
756
|
/**
|
|
637
757
|
* The codes a connection fails with before any request is sent: the host is
|
|
638
758
|
* not there, or is not who its certificate says. A failure after that, or a
|
|
@@ -649,7 +769,10 @@ function neverReached(error) {
|
|
|
649
769
|
* Hands the pull to the facilitator as the payment a seller would settle,
|
|
650
770
|
* `exact` to the payee; it checks the pull against the mandate, submits it and
|
|
651
771
|
* 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.
|
|
772
|
+
* reason for not settling; whether the pull paid is the chain's to say. One
|
|
773
|
+
* with no transaction and a reason other than doubt or a revert sent none,
|
|
774
|
+
* as did a 4xx answer that names no reason, such as from a URL it does not
|
|
775
|
+
* serve.
|
|
653
776
|
*/
|
|
654
777
|
async function sendToFacilitator(target, terms, kept) {
|
|
655
778
|
const paymentPayload = {
|
|
@@ -686,6 +809,14 @@ async function sendToFacilitator(target, terms, kept) {
|
|
|
686
809
|
const settled = (typeof answer === "object" && answer !== null ? answer : {});
|
|
687
810
|
const transaction = typeof settled.transaction === "string" && isHash(settled.transaction) ? settled.transaction : undefined;
|
|
688
811
|
const said = [settled.errorReason, settled.errorMessage].filter((part) => typeof part === "string" && part !== "").join(": ");
|
|
812
|
+
const reason = typeof settled.errorReason === "string" ? settled.errorReason : "";
|
|
813
|
+
// It sent this pull before, and that transaction reverted; it never sends it again.
|
|
814
|
+
if (transaction === undefined && reason === "payment_reverted")
|
|
815
|
+
return { kind: "sent", transaction: undefined, why: "the facilitator sent this pull and it reverted" };
|
|
816
|
+
if (transaction === undefined && !IN_DOUBT.has(reason) && (reason !== "" || (response.status >= 400 && response.status < 500))) {
|
|
817
|
+
const cause = said === "" ? `the facilitator answered HTTP ${response.status}, sending no transaction` : `the facilitator refused it, sending no transaction: ${said}`;
|
|
818
|
+
return { kind: "refused", cause: cause.replace(UNSHOWN, "?").slice(0, 300) };
|
|
819
|
+
}
|
|
689
820
|
const why = said !== "" ? `the facilitator says ${said}` : settled.success === true ? "the facilitator says it settled" : `the facilitator answered HTTP ${response.status}`;
|
|
690
821
|
return { kind: "sent", transaction, why: why.replace(UNSHOWN, "?").slice(0, 300) };
|
|
691
822
|
}
|
|
@@ -697,7 +828,22 @@ async function sendToFacilitator(target, terms, kept) {
|
|
|
697
828
|
async function sendFromKey(publicClient, sender, call) {
|
|
698
829
|
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
830
|
}
|
|
700
|
-
|
|
831
|
+
/**
|
|
832
|
+
* The topic of an event the account emitted under the mandate, its reference
|
|
833
|
+
* in the first topic, that Mandate Binding's ABI does not decode: an account
|
|
834
|
+
* of another version than the SDK's. Each log is matched to its decoding by
|
|
835
|
+
* transaction and position, so one under a known topic in another layout is
|
|
836
|
+
* seen too.
|
|
837
|
+
*/
|
|
838
|
+
function undecodedTopic(principal, reference, logs) {
|
|
839
|
+
const under = logs.filter((log) => isAddressEqual(log.address, principal) && log.topics[1]?.toLowerCase() === reference.toLowerCase());
|
|
840
|
+
const decoded = new Set(parseEventLogs({ abi: mandateBindingAbi, logs: under, strict: true }).map((log) => `${log.transactionHash}:${log.logIndex}`));
|
|
841
|
+
return under.find((log) => !decoded.has(`${log.transactionHash}:${log.logIndex}`))?.topics[0];
|
|
842
|
+
}
|
|
843
|
+
function undecodedPull(terms, found) {
|
|
844
|
+
return new Error(`the account ${terms.principal} emitted an event under the mandate under topic ${found.topic}, which this SDK does not decode, so the pull may have paid: do not pay it under another payment id; the SDK must be the account's version to read it. ` +
|
|
845
|
+
`${found.transaction === undefined ? "" : `Transaction ${found.transaction} carries it. `}The same pull sends nothing while that event is under the mandate`);
|
|
846
|
+
}
|
|
701
847
|
/** Blocks per log search, under the node's own limit on one filter's range. */
|
|
702
848
|
const LOG_CHUNK = 10000n;
|
|
703
849
|
/**
|
|
@@ -711,9 +857,10 @@ const FINAL = ["Revoked", "Expired", "BadSignature", "PayeeNotAllowed", "OverPer
|
|
|
711
857
|
* when one is named or in the account's own log between the block it was
|
|
712
858
|
* signed at and the end of its window; never to land, once its window has
|
|
713
859
|
* closed or the mandate refuses it for good; or still open. A read that fails
|
|
714
|
-
* leaves it open.
|
|
860
|
+
* leaves it open. An event under the mandate that does not decode leaves the
|
|
861
|
+
* outcome unreadable by this SDK.
|
|
715
862
|
*/
|
|
716
|
-
async function outcomeOnChain(publicClient, terms, kept, transaction) {
|
|
863
|
+
async function outcomeOnChain(publicClient, terms, reference, kept, transaction) {
|
|
717
864
|
const digest = slice(kept.pull.nonce, 12);
|
|
718
865
|
let unreadReceipt = transaction !== undefined;
|
|
719
866
|
try {
|
|
@@ -722,15 +869,24 @@ async function outcomeOnChain(publicClient, terms, kept, transaction) {
|
|
|
722
869
|
unreadReceipt = false;
|
|
723
870
|
if (pullEventOf(terms, digest, receipt) !== undefined)
|
|
724
871
|
return { kind: "landed", receipt };
|
|
872
|
+
const topic = undecodedTopic(terms.principal, reference, receipt.logs);
|
|
873
|
+
if (topic !== undefined)
|
|
874
|
+
return { kind: "undecoded", topic, transaction };
|
|
725
875
|
}
|
|
726
876
|
const head = await publicClient.getBlock();
|
|
727
877
|
// A pull lands only before its window ends, so the search stops at the first range that reaches it.
|
|
728
878
|
for (let from = kept.fromBlock; from <= head.number; from += LOG_CHUNK) {
|
|
729
879
|
const to = from + LOG_CHUNK - 1n < head.number ? from + LOG_CHUNK - 1n : head.number;
|
|
730
|
-
|
|
880
|
+
// Every event under the mandate, so one this SDK does not decode is seen rather than dropped.
|
|
881
|
+
const raw = (await publicClient.request({ method: "eth_getLogs", params: [{ address: terms.principal, fromBlock: numberToHex(from), toBlock: numberToHex(to), topics: [null, reference] }] }));
|
|
882
|
+
const formatted = raw.map((log) => formatLog(log));
|
|
883
|
+
const logs = parseEventLogs({ abi: mandateBindingAbi, eventName: ["MandatePayment", "MandateRefused"], logs: formatted });
|
|
731
884
|
const log = logs.find((entry) => entry.args.l3aDigest === digest);
|
|
732
885
|
if (log !== undefined)
|
|
733
886
|
return { kind: "landed", receipt: await publicClient.getTransactionReceipt({ hash: log.transactionHash }) };
|
|
887
|
+
const topic = undecodedTopic(terms.principal, reference, formatted);
|
|
888
|
+
if (topic !== undefined)
|
|
889
|
+
return { kind: "undecoded", topic, transaction };
|
|
734
890
|
if (to === head.number || (await publicClient.getBlock({ blockNumber: to })).timestamp >= kept.pull.validBefore)
|
|
735
891
|
break;
|
|
736
892
|
}
|
|
@@ -804,3 +960,9 @@ function printPull(target, terms, pull, receipt, print) {
|
|
|
804
960
|
export function parsePullAmount(text, option) {
|
|
805
961
|
return BigInt(usdzBaseUnits(parseCents(text, option)));
|
|
806
962
|
}
|
|
963
|
+
/** A payment id as `--payment-id` takes it: any text but none, or `undefined` when the option is absent. */
|
|
964
|
+
export function parsePaymentId(text, option) {
|
|
965
|
+
if (text === "")
|
|
966
|
+
throw new Error(`${option} must not be empty; leave it out to key the payout by its payee and amount`);
|
|
967
|
+
return text;
|
|
968
|
+
}
|
package/dist/commands/pay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { decodePaymentRequiredHeader, decodePaymentResponseHeader } from "@x402/core/http";
|
|
2
|
-
import { DEFAULT_MAX_PAYMENT, createMetalPaymentClient, formatUsdz, USDZ, usdzBaseUnits, } from "@metalntwx/metal-pay";
|
|
2
|
+
import { DEFAULT_MAX_PAYMENT, createMetalPaymentClient, formatUsdz, metalNetwork, USDZ, usdzBaseUnits, } from "@metalntwx/metal-pay";
|
|
3
3
|
import { createMandatePaymentClient, describeRefusal, refusalOf, signedOfferOf } from "@metalntwx/metal-pay/mandates";
|
|
4
4
|
import { loadMandate, mandateAgentSigner } from "./mandate.js";
|
|
5
5
|
/** A decimal `*USDZ` limit per payment, `DEFAULT_MAX_PAYMENT` when absent. */
|
|
@@ -64,6 +64,71 @@ export async function payUnderMandate(target, payer, url, print, request = {}) {
|
|
|
64
64
|
print(`mandate ${paying.reference}`);
|
|
65
65
|
await paidRequest(target, { ...paying, payer: paying.principal }, url, print, request);
|
|
66
66
|
}
|
|
67
|
+
/** A payment refused with nothing paid, before settlement or at it; `reason` is the facilitator's or the seller's, when it gave one. */
|
|
68
|
+
export class PaymentRefused extends Error {
|
|
69
|
+
reason;
|
|
70
|
+
constructor(message, reason) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.reason = reason;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A payment that was signed and may have settled: paying again can pay twice. */
|
|
76
|
+
export class PaymentInDoubt extends Error {
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The facilitator's settlement reasons that mean nothing moved
|
|
80
|
+
* (apps/x402-facilitator/src/facilitator.ts `check`, `settle`, `failed` and
|
|
81
|
+
* routes.ts): the payment was never sent, was refused or reverted. Any other
|
|
82
|
+
* answer, such as a seller's own `fetch failed` when the facilitator's answer
|
|
83
|
+
* to it was lost, or `already_settled`, `nonce_already_used` or
|
|
84
|
+
* `payment_quarantined`, which name a transaction that moved it, leaves open
|
|
85
|
+
* whether it settled.
|
|
86
|
+
*/
|
|
87
|
+
const NOTHING_SETTLED = new Set([
|
|
88
|
+
"unsupported_x402_version",
|
|
89
|
+
"unsupported_network",
|
|
90
|
+
"unsupported_scheme",
|
|
91
|
+
"unsupported_asset",
|
|
92
|
+
"settlement_unavailable",
|
|
93
|
+
"invalid_payload",
|
|
94
|
+
"bad_signature",
|
|
95
|
+
"rpc_unreachable",
|
|
96
|
+
"authorization_window",
|
|
97
|
+
"rate_limited",
|
|
98
|
+
"daily_limit_reached",
|
|
99
|
+
"recipient_refuses",
|
|
100
|
+
"payment_failed",
|
|
101
|
+
"payment_reverted",
|
|
102
|
+
]);
|
|
103
|
+
function nothingSettled(reason) {
|
|
104
|
+
if (reason === undefined)
|
|
105
|
+
return false;
|
|
106
|
+
const mandate = refusalOf(reason);
|
|
107
|
+
// A used digest is a payment that was made, or refused, already.
|
|
108
|
+
if (mandate !== undefined)
|
|
109
|
+
return mandate !== "NonceUsed";
|
|
110
|
+
return NOTHING_SETTLED.has(reason) || reason.startsWith("payment_mismatch:");
|
|
111
|
+
}
|
|
112
|
+
/** The networks an offer named when none is the one paid on, read from @x402/core's error for an offer with no option it can sign. */
|
|
113
|
+
function offeredElsewhere(message, target) {
|
|
114
|
+
const marker = "Failed to create payment payload: No network/scheme registered";
|
|
115
|
+
if (!message.startsWith(marker) || !message.includes("{"))
|
|
116
|
+
return undefined;
|
|
117
|
+
try {
|
|
118
|
+
const { paymentRequirements } = JSON.parse(message.slice(message.indexOf("{")));
|
|
119
|
+
const networks = [...new Set((paymentRequirements ?? []).map(({ network }) => network).filter((network) => typeof network === "string"))];
|
|
120
|
+
if (networks.length === 0 || networks.includes(target.network.network))
|
|
121
|
+
return undefined;
|
|
122
|
+
const named = networks.map((network) => {
|
|
123
|
+
const metal = metalNetwork(network);
|
|
124
|
+
return metal === undefined ? network : `${metal.name} (${network})`;
|
|
125
|
+
});
|
|
126
|
+
return `the offer is on ${named.join(", ")}, not ${target.network.name} (${target.network.network})`;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
67
132
|
/** A refusal's reason as `pay` prints it: a mandate's with what it means, any other as the facilitator named it. */
|
|
68
133
|
function describeRefusalReason(reason) {
|
|
69
134
|
if (reason === undefined)
|
|
@@ -102,10 +167,10 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
|
|
|
102
167
|
const how = raise !== undefined && aborted[1].endsWith("limit per payment") ? `; ${raise}` : "";
|
|
103
168
|
throw new Error(`payment refused before signing: ${aborted[1]}${how}`);
|
|
104
169
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
throw
|
|
170
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
171
|
+
if (signed !== undefined)
|
|
172
|
+
throw new PaymentInDoubt(`${message}; a payment was signed and may have settled; look it up by its nonce before paying again`);
|
|
173
|
+
throw new Error(`${offeredElsewhere(message, target) ?? message}; nothing was signed`);
|
|
109
174
|
});
|
|
110
175
|
const body = await response.text();
|
|
111
176
|
const paymentRequired = response.headers.get("payment-required");
|
|
@@ -114,16 +179,24 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
|
|
|
114
179
|
const required = decodePaymentRequiredHeader(paymentRequired);
|
|
115
180
|
const offer = required.accepts[0];
|
|
116
181
|
const priced = offer ? `${formatUsdz(offer.amount)} ${USDZ.symbol} on ${offer.network}` : "no offer";
|
|
117
|
-
|
|
182
|
+
// Refused before settlement: the seller settles only a payment its check passed.
|
|
183
|
+
throw new PaymentRefused(`payment refused: ${describeRefusalReason(required.error)} (offer: ${priced}); nothing was paid`, required.error);
|
|
118
184
|
}
|
|
119
185
|
const settlement = paymentResponse ? decodePaymentResponseHeader(paymentResponse) : undefined;
|
|
120
|
-
if (settlement !== undefined && !settlement.success)
|
|
121
|
-
|
|
186
|
+
if (settlement !== undefined && !settlement.success) {
|
|
187
|
+
// A settlement that is not known to have moved nothing can land all the same, so paying again can pay twice.
|
|
188
|
+
if (!nothingSettled(settlement.errorReason))
|
|
189
|
+
throw new PaymentInDoubt(`payment in doubt at settlement: ${settleRefusal(settlement)}; it may have settled: do not pay again; look it up by its nonce`);
|
|
190
|
+
throw new PaymentRefused(`payment refused at settlement: ${settleRefusal(settlement)}; nothing was paid`, settlement.errorReason);
|
|
191
|
+
}
|
|
122
192
|
// A redirect is reported, not an error: the payment may have settled, and `location` says where it points.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
193
|
+
// A settled payment is reported paid, whatever status the seller answers with.
|
|
194
|
+
if (response.status >= 400 && settlement?.success !== true) {
|
|
195
|
+
// A seller that fails after the payment was signed, or answers that another request holds it, may have settled it first.
|
|
196
|
+
const answered = `${url} answered ${response.status}: ${body}`;
|
|
197
|
+
if (signed !== undefined && (response.status >= 500 || response.status === 409))
|
|
198
|
+
throw new PaymentInDoubt(`${answered}; a payment was signed and may have settled; look it up by its nonce before paying again`);
|
|
199
|
+
throw new Error(answered);
|
|
127
200
|
}
|
|
128
201
|
print(`status ${response.status}`);
|
|
129
202
|
if (settlement !== undefined) {
|