@bananapus/nana-sdk-core 2.24.1 → 2.24.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/review/relayr.d.ts +21 -7
- package/dist/cjs/review/relayr.d.ts.map +1 -1
- package/dist/cjs/review/relayr.js +120 -17
- package/dist/cjs/review/relayr.js.map +1 -1
- package/dist/cjs/review/safeRelayr.d.ts +31 -3
- package/dist/cjs/review/safeRelayr.d.ts.map +1 -1
- package/dist/cjs/review/safeRelayr.js +410 -121
- package/dist/cjs/review/safeRelayr.js.map +1 -1
- package/dist/esm/review/relayr.d.ts +21 -7
- package/dist/esm/review/relayr.d.ts.map +1 -1
- package/dist/esm/review/relayr.js +117 -17
- package/dist/esm/review/relayr.js.map +1 -1
- package/dist/esm/review/safeRelayr.d.ts +31 -3
- package/dist/esm/review/safeRelayr.d.ts.map +1 -1
- package/dist/esm/review/safeRelayr.js +410 -122
- package/dist/esm/review/safeRelayr.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,66 @@ import { decodeFunctionData, encodeFunctionData, isAddress, keccak256, toHex, pa
|
|
|
2
2
|
import { readBoundedSafeNonce } from "../safe.js";
|
|
3
3
|
import { SAFE_EXEC_ABI, canonicalSafeTxHash, requireSafeExecutionSuccess, safeExecutionResult, } from "../safeService.js";
|
|
4
4
|
import { isDefiniteWalletRejection } from "./contractWrite.js";
|
|
5
|
-
import { RELAYR_API, RELAYR_UUID_RE, MAX_RELAYR_SENT_PAYMENTS, atCanonicalFinalizedBlock, bindRelayrQuote, readRelayrBundle, relayrBundleRequest, relayrDeadlinePassed, relayrPaymentOptions, relayrPaymentDetails, relayrRetryOption, relayrSentPaymentsSnapshot, relayrQuotedOptions, requireRelayrBundleUnpaid, requireRelayrRetry, revertedRelayrQuote, verifyRelayrDestinations, verifyRelayrPayment, RelayrPaymentRevertedError, } from "./relayr.js";
|
|
5
|
+
import { RELAYR_API, RELAYR_UUID_RE, MAX_RELAYR_SENT_PAYMENTS, atCanonicalFinalizedBlock, bindRelayrQuote, readRelayrBundle, relayrBundleRequest, relayrDeadlinePassed, relayrPaymentOptions, relayrPaymentDetails, relayrDestinationHash, relayrDestinationHashes, relayrRetryOption, relayrSentPaymentsSnapshot, relayrStateIsPending, relayrStateIsFailed, relayrQuotedOptions, requireRelayrBundleUnpaid, requireRelayrRetry, revertedRelayrQuote, verifyRelayrDestinations, verifyRelayrDestination, verifyRelayrPayment, RelayrPaymentRevertedError, RelayrProofUnavailableError, } from "./relayr.js";
|
|
6
|
+
function hasFundingRecords(records) {
|
|
7
|
+
if (records === undefined)
|
|
8
|
+
return false;
|
|
9
|
+
if (!Array.isArray(records))
|
|
10
|
+
return true;
|
|
11
|
+
return records.some((record) => {
|
|
12
|
+
if (!record || typeof record !== "object" || Array.isArray(record))
|
|
13
|
+
return true;
|
|
14
|
+
const status = record.status;
|
|
15
|
+
if (status === undefined)
|
|
16
|
+
return false;
|
|
17
|
+
if (!status || typeof status !== "object" || Array.isArray(status))
|
|
18
|
+
return true;
|
|
19
|
+
const data = status.data;
|
|
20
|
+
if (data !== undefined &&
|
|
21
|
+
data !== null &&
|
|
22
|
+
(typeof data !== "object" || Array.isArray(data)))
|
|
23
|
+
return true;
|
|
24
|
+
const transaction = data?.transaction;
|
|
25
|
+
if (transaction !== undefined &&
|
|
26
|
+
(transaction === null ||
|
|
27
|
+
typeof transaction !== "object" ||
|
|
28
|
+
Array.isArray(transaction)))
|
|
29
|
+
return true;
|
|
30
|
+
if (status.data?.hash !== undefined ||
|
|
31
|
+
status.data?.transaction?.hash !== undefined)
|
|
32
|
+
return true;
|
|
33
|
+
return status.state !== undefined && !relayrStateIsPending(status.state);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function hasObservedFunding(session) {
|
|
37
|
+
return ((session.fundingObserved !== undefined &&
|
|
38
|
+
session.fundingObserved !== false) ||
|
|
39
|
+
hasFundingRecords(session.records) ||
|
|
40
|
+
hasFundingRecords(session.quote?.transactions));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Safe signatures already authorize a nonce-protected call. Publishing or losing
|
|
44
|
+
* an unused quote is not a funding attempt. Only this Safe-specific owner may
|
|
45
|
+
* replace such a quote; raw/forwarded authorizations keep their existing rules.
|
|
46
|
+
*/
|
|
47
|
+
export function canReplaceSafeRelayrQuote(session) {
|
|
48
|
+
if ((session.state !== "publishing" && session.state !== "active") ||
|
|
49
|
+
session.paymentStatus !== "unfunded" ||
|
|
50
|
+
!Array.isArray(session.payments) ||
|
|
51
|
+
session.payments.length !== 0 ||
|
|
52
|
+
(session.reservationKeys !== undefined &&
|
|
53
|
+
!Array.isArray(session.reservationKeys)) ||
|
|
54
|
+
hasObservedFunding(session))
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
validateExecutions(session.executions);
|
|
58
|
+
const identities = new Set(session.executions.map(safeRelayrReservationKey));
|
|
59
|
+
return !session.reservationKeys?.some((key) => !identities.has(key.toLowerCase()));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
6
65
|
export class SafeRelayrRecoveryError extends Error {
|
|
7
66
|
constructor(session, message = "This Safe execution has an existing Relayr bundle. Check the existing bundle before submitting another payment.", recovery) {
|
|
8
67
|
super(message);
|
|
@@ -162,6 +221,13 @@ function requireSafeRelayrRecords(bindings, records) {
|
|
|
162
221
|
throw new Error("Relayr's record does not match the saved Safe transaction binding.");
|
|
163
222
|
}
|
|
164
223
|
}
|
|
224
|
+
function requireSafeLanding(execution, { receipt }) {
|
|
225
|
+
const { safe, safeTxHash } = execution;
|
|
226
|
+
requireSafeExecutionSuccess(receipt, safe, safeTxHash);
|
|
227
|
+
const event = safeExecutionResult(receipt, safe, safeTxHash);
|
|
228
|
+
if (event.status !== "success" || event.payment !== 0n)
|
|
229
|
+
throw new Error("The Safe execution reimbursed its executor. Keep the existing bundle for verification.");
|
|
230
|
+
}
|
|
165
231
|
/** Canonical exact destination receipts plus matching, refund-free Safe success events. */
|
|
166
232
|
export async function verifySafeRelayrLanding(clientFor, { executions, bindings, records, }) {
|
|
167
233
|
validateExecutions(executions);
|
|
@@ -174,13 +240,7 @@ export async function verifySafeRelayrLanding(clientFor, { executions, bindings,
|
|
|
174
240
|
bindings,
|
|
175
241
|
records,
|
|
176
242
|
});
|
|
177
|
-
verified.forEach((
|
|
178
|
-
const { safe, safeTxHash } = executions[index];
|
|
179
|
-
requireSafeExecutionSuccess(receipt, safe, safeTxHash);
|
|
180
|
-
const event = safeExecutionResult(receipt, safe, safeTxHash);
|
|
181
|
-
if (event.status !== "success" || event.payment !== 0n)
|
|
182
|
-
throw new Error("The Safe execution reimbursed its executor. Keep the existing bundle for verification.");
|
|
183
|
-
});
|
|
243
|
+
verified.forEach((item, index) => requireSafeLanding(executions[index], item));
|
|
184
244
|
return verified;
|
|
185
245
|
}
|
|
186
246
|
/** Validate persisted bindings again; local storage is not authenticated evidence. */
|
|
@@ -206,6 +266,49 @@ function requireSessionQuote(session) {
|
|
|
206
266
|
export function createSafeRelayrController(options) {
|
|
207
267
|
const { store } = options;
|
|
208
268
|
const fetchRelayr = options.fetch ?? globalThis.fetch;
|
|
269
|
+
const displayedExecutions = new Map();
|
|
270
|
+
const notify = (progress, rechecking = false) => {
|
|
271
|
+
if (progress.type === "execution") {
|
|
272
|
+
const key = `${progress.session.id}:${progress.index}`;
|
|
273
|
+
const display = JSON.stringify([
|
|
274
|
+
progress.status,
|
|
275
|
+
progress.hash,
|
|
276
|
+
progress.message,
|
|
277
|
+
]);
|
|
278
|
+
const previous = displayedExecutions.get(key);
|
|
279
|
+
// Rechecking does not undo a proven display result. A later unavailable,
|
|
280
|
+
// contradictory, or changed-hash result explicitly updates it below.
|
|
281
|
+
if (previous === display ||
|
|
282
|
+
(rechecking &&
|
|
283
|
+
previous === JSON.stringify(["executed", progress.hash, undefined])))
|
|
284
|
+
return;
|
|
285
|
+
displayedExecutions.set(key, display);
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
// A view must not mutate frozen calls or turn a submitted payment into a
|
|
289
|
+
// rejected operation. Also consume rejection from an async UI callback.
|
|
290
|
+
void Promise.resolve(options.onProgress?.(structuredClone(progress))).catch(() => undefined);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Display observers cannot change the durable execution lifecycle.
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const phase = (phase, session, signal) => {
|
|
297
|
+
if (!signal?.aborted)
|
|
298
|
+
notify({ type: "phase", phase, session });
|
|
299
|
+
};
|
|
300
|
+
const savedPhase = (session, signal) => {
|
|
301
|
+
if (session.state === "released")
|
|
302
|
+
return;
|
|
303
|
+
if (session.state === "complete")
|
|
304
|
+
phase("complete", session, signal);
|
|
305
|
+
else if (session.paymentStatus === "confirmed" ||
|
|
306
|
+
hasObservedFunding(session))
|
|
307
|
+
phase("executing", session, signal);
|
|
308
|
+
else if (session.paymentStatus === "submitted" ||
|
|
309
|
+
session.paymentStatus === "sending")
|
|
310
|
+
phase("payment-confirming", session, signal);
|
|
311
|
+
};
|
|
209
312
|
const checkAccount = (account, signal) => {
|
|
210
313
|
signal?.throwIfAborted();
|
|
211
314
|
if (!isAddress(account) ||
|
|
@@ -263,10 +366,12 @@ export function createSafeRelayrController(options) {
|
|
|
263
366
|
};
|
|
264
367
|
const unidentified = (session) => !session.executions.length && !session.reservationKeys?.length;
|
|
265
368
|
const unresolved = (session) => session.state !== "complete" && session.state !== "released";
|
|
369
|
+
const reserves = (session, executions) => unresolved(session) &&
|
|
370
|
+
(store.scope === "single-session" ||
|
|
371
|
+
unidentified(session) ||
|
|
372
|
+
overlap(session, executions));
|
|
266
373
|
const assertUnreserved = async (account, executions, exceptId) => {
|
|
267
|
-
const conflict = (await store.list(account)).find((session) => session.id !== exceptId &&
|
|
268
|
-
unresolved(session) &&
|
|
269
|
-
(unidentified(session) || overlap(session, executions)));
|
|
374
|
+
const conflict = (await store.list(account)).find((session) => session.id !== exceptId && reserves(session, executions));
|
|
270
375
|
if (conflict)
|
|
271
376
|
throw new SafeRelayrRecoveryError(conflict);
|
|
272
377
|
};
|
|
@@ -339,7 +444,8 @@ export function createSafeRelayrController(options) {
|
|
|
339
444
|
// session per Safe and replacing it would erase the unresolved evidence.
|
|
340
445
|
if (session.paymentStatus !== "unfunded" ||
|
|
341
446
|
!Array.isArray(session.payments) ||
|
|
342
|
-
session.payments.length
|
|
447
|
+
session.payments.length ||
|
|
448
|
+
hasObservedFunding(session))
|
|
343
449
|
return pending({
|
|
344
450
|
reason: "funding-unresolved",
|
|
345
451
|
message: "Every saved Safe nonce has been used, but this selection still has unresolved payment evidence. Inspect the original Safe proposals and funding transactions before replacing it. No refund or successful execution has been confirmed.",
|
|
@@ -382,77 +488,256 @@ export function createSafeRelayrController(options) {
|
|
|
382
488
|
checks,
|
|
383
489
|
});
|
|
384
490
|
}
|
|
385
|
-
async function inspect(session) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
records.every((record) => record.status?.data?.hash || record.status?.data?.transaction?.hash)) {
|
|
407
|
-
const verified = await verifySafeRelayrLanding(options.clientFor, {
|
|
408
|
-
executions: session.executions,
|
|
409
|
-
bindings: quote.expectedTransactions,
|
|
410
|
-
records,
|
|
491
|
+
async function inspect(session, signal) {
|
|
492
|
+
const report = (progress, rechecking = false) => {
|
|
493
|
+
if (!signal?.aborted)
|
|
494
|
+
notify(progress, rechecking);
|
|
495
|
+
};
|
|
496
|
+
try {
|
|
497
|
+
savedPhase(session, signal);
|
|
498
|
+
if (session.state === "released")
|
|
499
|
+
return result("released", session);
|
|
500
|
+
if (session.state === "complete")
|
|
501
|
+
return result("complete", session);
|
|
502
|
+
if (hasObservedFunding(session) && session.fundingObserved !== true) {
|
|
503
|
+
session = { ...session, fundingObserved: true };
|
|
504
|
+
await store.save(session);
|
|
505
|
+
}
|
|
506
|
+
if (!session.bundleUuid || !session.quote)
|
|
507
|
+
return inspectMissingQuote(session);
|
|
508
|
+
const quote = requireSessionQuote(session);
|
|
509
|
+
const chains = session.executions.map(({ entry }) => entry.chain);
|
|
510
|
+
const bundle = await readRelayrBundle(session.bundleUuid, {
|
|
511
|
+
fetch: fetchRelayr,
|
|
411
512
|
});
|
|
412
|
-
|
|
413
|
-
|
|
513
|
+
if (hasObservedFunding(session) ||
|
|
514
|
+
bundle.payment_received === true ||
|
|
515
|
+
hasFundingRecords(bundle.transactions)) {
|
|
516
|
+
session = { ...session, fundingObserved: true };
|
|
517
|
+
await store.save(session);
|
|
518
|
+
}
|
|
519
|
+
if (!Array.isArray(bundle.transactions))
|
|
520
|
+
throw new SafeRelayrRecoveryError(session, "Relayr has not returned this bundle's transactions. Check the existing bundle again.");
|
|
521
|
+
const records = bundle.transactions;
|
|
522
|
+
requireSafeRelayrRecords(quote.expectedTransactions, records);
|
|
523
|
+
session = { ...session, records };
|
|
414
524
|
await store.save(session);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
return result("pending", session);
|
|
525
|
+
savedPhase(session, signal);
|
|
526
|
+
// Each hash is only a pointer. Check available chains independently, using
|
|
527
|
+
// the same complete binding validator and exact receipt proof as completion.
|
|
528
|
+
const indexed = quote.expectedTransactions.map((binding, index) => ({
|
|
529
|
+
binding,
|
|
530
|
+
index,
|
|
531
|
+
record: records.find((record) => record.tx_uuid?.toLowerCase() === binding.txUuid.toLowerCase()),
|
|
532
|
+
}));
|
|
533
|
+
const known = indexed.filter(({ record }) => record.status?.data?.hash !== undefined ||
|
|
534
|
+
record.status?.data?.transaction?.hash !== undefined);
|
|
535
|
+
if (known.length) {
|
|
427
536
|
try {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
payment,
|
|
537
|
+
relayrDestinationHashes({
|
|
538
|
+
bindings: known.map(({ binding }) => binding),
|
|
539
|
+
records: known.map(({ record }) => record),
|
|
432
540
|
});
|
|
433
|
-
session = { ...session, paymentStatus: "confirmed" };
|
|
434
|
-
await store.save(session);
|
|
435
|
-
return result("pending", session);
|
|
436
541
|
}
|
|
437
542
|
catch (error) {
|
|
438
|
-
|
|
439
|
-
|
|
543
|
+
for (const { index, record } of known)
|
|
544
|
+
report({
|
|
545
|
+
type: "execution",
|
|
546
|
+
session,
|
|
547
|
+
index,
|
|
548
|
+
execution: session.executions[index],
|
|
549
|
+
status: "failed",
|
|
550
|
+
hash: relayrDestinationHash(record) ?? undefined,
|
|
551
|
+
message: error instanceof Error
|
|
552
|
+
? error.message
|
|
553
|
+
: "The destination records do not match this quote.",
|
|
554
|
+
});
|
|
555
|
+
throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
|
|
440
556
|
}
|
|
557
|
+
phase("executing", session, signal);
|
|
441
558
|
}
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
559
|
+
if (known.length ||
|
|
560
|
+
session.paymentStatus !== "unfunded" ||
|
|
561
|
+
session.fundingObserved) {
|
|
562
|
+
for (const { index, record } of indexed) {
|
|
563
|
+
const hash = relayrDestinationHash(record) ?? undefined;
|
|
564
|
+
report({
|
|
565
|
+
type: "execution",
|
|
566
|
+
session,
|
|
567
|
+
index,
|
|
568
|
+
execution: session.executions[index],
|
|
569
|
+
status: hash
|
|
570
|
+
? "confirming"
|
|
571
|
+
: relayrStateIsFailed(record.status?.state)
|
|
572
|
+
? "failed"
|
|
573
|
+
: "pending",
|
|
574
|
+
hash,
|
|
575
|
+
...(!hash && relayrStateIsFailed(record.status?.state)
|
|
576
|
+
? {
|
|
577
|
+
message: "Relayr reported this execution failed. Its onchain result has not been verified.",
|
|
578
|
+
}
|
|
579
|
+
: {}),
|
|
580
|
+
}, true);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const landed = await Promise.allSettled(known.map(async ({ binding, index, record }) => {
|
|
584
|
+
const execution = session.executions[index];
|
|
585
|
+
const hash = relayrDestinationHash(record);
|
|
586
|
+
const client = options.clientFor(binding.chain);
|
|
587
|
+
if (!client) {
|
|
588
|
+
report({
|
|
589
|
+
type: "execution",
|
|
590
|
+
session,
|
|
591
|
+
index,
|
|
592
|
+
execution,
|
|
593
|
+
status: "confirming",
|
|
594
|
+
hash,
|
|
595
|
+
message: "Waiting for a connection to this network.",
|
|
596
|
+
});
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
const receipt = await verifyRelayrDestination(client, {
|
|
601
|
+
entry: binding.entry,
|
|
602
|
+
hash,
|
|
603
|
+
});
|
|
604
|
+
const verified = {
|
|
605
|
+
txUuid: binding.txUuid,
|
|
606
|
+
chainId: binding.chain,
|
|
607
|
+
receipt,
|
|
608
|
+
};
|
|
609
|
+
requireSafeLanding(execution, verified);
|
|
610
|
+
await options.afterVerified?.(execution, verified);
|
|
611
|
+
report({
|
|
612
|
+
type: "execution",
|
|
613
|
+
session,
|
|
614
|
+
index,
|
|
615
|
+
execution,
|
|
616
|
+
status: "executed",
|
|
617
|
+
hash,
|
|
618
|
+
});
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
if (error instanceof RelayrProofUnavailableError) {
|
|
623
|
+
report({
|
|
624
|
+
type: "execution",
|
|
625
|
+
session,
|
|
626
|
+
index,
|
|
627
|
+
execution,
|
|
628
|
+
status: "confirming",
|
|
629
|
+
hash,
|
|
630
|
+
message: "Waiting for the network to confirm this transaction.",
|
|
631
|
+
});
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
report({
|
|
635
|
+
type: "execution",
|
|
636
|
+
session,
|
|
637
|
+
index,
|
|
638
|
+
execution,
|
|
639
|
+
status: "failed",
|
|
640
|
+
hash,
|
|
641
|
+
message: error instanceof Error
|
|
642
|
+
? error.message
|
|
643
|
+
: "The Safe execution could not be verified.",
|
|
644
|
+
});
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
}));
|
|
648
|
+
const failure = landed.find((item) => item.status === "rejected");
|
|
649
|
+
if (failure?.status === "rejected")
|
|
650
|
+
throw new SafeRelayrRecoveryError(session, failure.reason instanceof Error ? failure.reason.message : undefined);
|
|
651
|
+
if (known.length === session.executions.length &&
|
|
652
|
+
landed.every((item) => item.status === "fulfilled" && item.value)) {
|
|
653
|
+
session = { ...session, state: "complete" };
|
|
654
|
+
await store.save(session);
|
|
655
|
+
phase("complete", session, signal);
|
|
656
|
+
return result("complete", session);
|
|
657
|
+
}
|
|
658
|
+
if (session.paymentStatus === "sending")
|
|
454
659
|
return result("pending", session);
|
|
455
|
-
if (
|
|
660
|
+
if (session.payments.some((payment) => payment.bundleUuid !== session.bundleUuid))
|
|
661
|
+
throw new SafeRelayrRecoveryError(session, "A saved payment belongs to another bundle. Keep the existing bundle for verification.");
|
|
662
|
+
if (session.payments.length) {
|
|
663
|
+
let everyReverted = true;
|
|
664
|
+
for (const payment of session.payments) {
|
|
665
|
+
const client = options.clientFor(payment.chainId);
|
|
666
|
+
if (!client)
|
|
667
|
+
return result("pending", session);
|
|
668
|
+
try {
|
|
669
|
+
await verifyRelayrPayment(client, {
|
|
670
|
+
hash: payment.hash,
|
|
671
|
+
from: session.account,
|
|
672
|
+
payment,
|
|
673
|
+
});
|
|
674
|
+
session = { ...session, paymentStatus: "confirmed" };
|
|
675
|
+
await store.save(session);
|
|
676
|
+
phase("executing", session, signal);
|
|
677
|
+
return result("pending", session);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
if (!(error instanceof RelayrPaymentRevertedError))
|
|
681
|
+
everyReverted = false;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (!everyReverted)
|
|
685
|
+
return result("pending", session);
|
|
686
|
+
session = { ...session, paymentStatus: "reverted" };
|
|
687
|
+
await store.save(session);
|
|
688
|
+
const disposition = await revertedRelayrQuote(options.clientFor, {
|
|
689
|
+
bundleUuid: quote.bundle_uuid,
|
|
690
|
+
payments: session.payments,
|
|
691
|
+
options: quote.payment_info,
|
|
692
|
+
destinationChainIds: chains,
|
|
693
|
+
account: session.account,
|
|
694
|
+
}, { fetch: fetchRelayr });
|
|
695
|
+
if (disposition.state === "funded")
|
|
696
|
+
return result("pending", session);
|
|
697
|
+
if (disposition.state === "released") {
|
|
698
|
+
session = {
|
|
699
|
+
...session,
|
|
700
|
+
state: "released",
|
|
701
|
+
paymentStatus: "expired",
|
|
702
|
+
releaseReason: "quote-expired",
|
|
703
|
+
};
|
|
704
|
+
await store.save(session);
|
|
705
|
+
return result("released", session);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else if (session.paymentStatus !== "unfunded" ||
|
|
709
|
+
session.fundingObserved) {
|
|
710
|
+
// A wallet invocation without a known hash may still have broadcast.
|
|
711
|
+
return session.fundingObserved && !known.length
|
|
712
|
+
? {
|
|
713
|
+
...result("pending", session),
|
|
714
|
+
recovery: {
|
|
715
|
+
reason: "funding-unresolved",
|
|
716
|
+
message: "This saved selection has reported funding or execution activity. Check the existing bundle and its funding transactions before submitting another payment.",
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
: result("pending", session);
|
|
720
|
+
}
|
|
721
|
+
const quoted = relayrQuotedOptions(quote, chains);
|
|
722
|
+
if (!quoted.length)
|
|
723
|
+
return inspectMissingQuote(session);
|
|
724
|
+
const payments = relayrPaymentOptions(quote, chains);
|
|
725
|
+
const expired = payments.length
|
|
726
|
+
? [false]
|
|
727
|
+
: await Promise.all(quoted.map(async ({ details }) => {
|
|
728
|
+
const client = options.clientFor(details.chainId);
|
|
729
|
+
return (!!client &&
|
|
730
|
+
(await relayrDeadlinePassed(client, details.deadline).catch(() => false)));
|
|
731
|
+
}));
|
|
732
|
+
if (expired.every(Boolean)) {
|
|
733
|
+
try {
|
|
734
|
+
await requireRelayrBundleUnpaid(quote.bundle_uuid, {
|
|
735
|
+
fetch: fetchRelayr,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return result("pending", session);
|
|
740
|
+
}
|
|
456
741
|
session = {
|
|
457
742
|
...session,
|
|
458
743
|
state: "released",
|
|
@@ -462,23 +747,6 @@ export function createSafeRelayrController(options) {
|
|
|
462
747
|
await store.save(session);
|
|
463
748
|
return result("released", session);
|
|
464
749
|
}
|
|
465
|
-
}
|
|
466
|
-
else if (session.paymentStatus !== "unfunded") {
|
|
467
|
-
// A wallet invocation without a known hash may still have broadcast.
|
|
468
|
-
return result("pending", session);
|
|
469
|
-
}
|
|
470
|
-
const quoted = relayrQuotedOptions(quote, chains);
|
|
471
|
-
if (!quoted.length)
|
|
472
|
-
return inspectMissingQuote(session);
|
|
473
|
-
const payments = relayrPaymentOptions(quote, chains);
|
|
474
|
-
const expired = payments.length
|
|
475
|
-
? [false]
|
|
476
|
-
: await Promise.all(quoted.map(async ({ details }) => {
|
|
477
|
-
const client = options.clientFor(details.chainId);
|
|
478
|
-
return (!!client &&
|
|
479
|
-
(await relayrDeadlinePassed(client, details.deadline).catch(() => false)));
|
|
480
|
-
}));
|
|
481
|
-
if (expired.every(Boolean)) {
|
|
482
750
|
try {
|
|
483
751
|
await requireRelayrBundleUnpaid(quote.bundle_uuid, {
|
|
484
752
|
fetch: fetchRelayr,
|
|
@@ -487,43 +755,35 @@ export function createSafeRelayrController(options) {
|
|
|
487
755
|
catch {
|
|
488
756
|
return result("pending", session);
|
|
489
757
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
await store.save(session);
|
|
497
|
-
return result("released", session);
|
|
498
|
-
}
|
|
499
|
-
try {
|
|
500
|
-
await requireRelayrBundleUnpaid(quote.bundle_uuid, {
|
|
501
|
-
fetch: fetchRelayr,
|
|
502
|
-
});
|
|
758
|
+
const allowed = session.payments.length && payments.length
|
|
759
|
+
? [relayrRetryOption(session.payments, payments)]
|
|
760
|
+
: payments;
|
|
761
|
+
return allowed.length
|
|
762
|
+
? result("ready", session, allowed)
|
|
763
|
+
: result("pending", session);
|
|
503
764
|
}
|
|
504
|
-
catch {
|
|
505
|
-
|
|
765
|
+
catch (error) {
|
|
766
|
+
if (error instanceof SafeRelayrRecoveryError)
|
|
767
|
+
throw error;
|
|
768
|
+
throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
|
|
506
769
|
}
|
|
507
|
-
const allowed = session.payments.length && payments.length
|
|
508
|
-
? [relayrRetryOption(session.payments, payments)]
|
|
509
|
-
: payments;
|
|
510
|
-
return allowed.length
|
|
511
|
-
? result("ready", session, allowed)
|
|
512
|
-
: result("pending", session);
|
|
513
770
|
}
|
|
514
771
|
async function prepare({ account, executions: input, signal, onStatus, }) {
|
|
515
772
|
const executions = structuredClone([...input]);
|
|
516
773
|
validateExecutions(executions);
|
|
517
774
|
return store.withLock(account, async () => {
|
|
518
775
|
checkAccount(account, signal);
|
|
519
|
-
const reservations = (await store.list(account)).filter((session) =>
|
|
520
|
-
(unidentified(session) || overlap(session, executions)));
|
|
776
|
+
const reservations = (await store.list(account)).filter((session) => reserves(session, executions));
|
|
521
777
|
for (const saved of reservations) {
|
|
778
|
+
if (canReplaceSafeRelayrQuote(saved))
|
|
779
|
+
continue;
|
|
522
780
|
let checked;
|
|
523
781
|
try {
|
|
524
|
-
checked = await inspect(saved);
|
|
782
|
+
checked = await inspect(saved, signal);
|
|
525
783
|
}
|
|
526
784
|
catch (error) {
|
|
785
|
+
if (error instanceof SafeRelayrRecoveryError)
|
|
786
|
+
throw error;
|
|
527
787
|
throw new SafeRelayrRecoveryError(saved, error instanceof Error ? error.message : undefined);
|
|
528
788
|
}
|
|
529
789
|
if (checked.state === "released" &&
|
|
@@ -537,14 +797,29 @@ export function createSafeRelayrController(options) {
|
|
|
537
797
|
throw new SafeRelayrRecoveryError(checked.session, checked.recovery?.message, checked.recovery);
|
|
538
798
|
await assertUnreserved(account, executions, saved.id);
|
|
539
799
|
await revalidate(saved.executions, account, onStatus, false, signal);
|
|
800
|
+
phase("reviewing", checked.session, signal);
|
|
540
801
|
await options.review(saved.executions, { resumed: true });
|
|
541
802
|
checkAccount(account, signal);
|
|
542
803
|
rememberReview(checked.session);
|
|
543
804
|
return { ...checked, state: "ready", resumed: true };
|
|
544
805
|
}
|
|
545
806
|
await revalidate(executions, account, onStatus, false, signal);
|
|
807
|
+
phase("reviewing", undefined, signal);
|
|
546
808
|
await options.review(executions, { resumed: false });
|
|
547
809
|
checkAccount(account, signal);
|
|
810
|
+
// Re-read under the shared lock after review. A rejected review leaves the
|
|
811
|
+
// old quote untouched; a recorded wallet invocation can never be retired.
|
|
812
|
+
for (const saved of await store.list(account)) {
|
|
813
|
+
if (!reserves(saved, executions))
|
|
814
|
+
continue;
|
|
815
|
+
if (!canReplaceSafeRelayrQuote(saved))
|
|
816
|
+
throw new SafeRelayrRecoveryError(saved);
|
|
817
|
+
await store.save({
|
|
818
|
+
...saved,
|
|
819
|
+
state: "released",
|
|
820
|
+
releaseReason: "quote-replaced",
|
|
821
|
+
});
|
|
822
|
+
}
|
|
548
823
|
await assertUnreserved(account, executions);
|
|
549
824
|
let session = {
|
|
550
825
|
id: options.createId?.() ?? globalThis.crypto.randomUUID(),
|
|
@@ -555,8 +830,8 @@ export function createSafeRelayrController(options) {
|
|
|
555
830
|
payments: [],
|
|
556
831
|
createdAt: Date.now(),
|
|
557
832
|
};
|
|
558
|
-
// Persist before POST
|
|
559
|
-
//
|
|
833
|
+
// Persist before POST so the quote can be recovered or retired later.
|
|
834
|
+
// Safe publication alone is not evidence that the wallet funded it.
|
|
560
835
|
await store.save(session);
|
|
561
836
|
try {
|
|
562
837
|
checkAccount(account, signal);
|
|
@@ -566,6 +841,7 @@ export function createSafeRelayrController(options) {
|
|
|
566
841
|
throw error;
|
|
567
842
|
}
|
|
568
843
|
const request = relayrBundleRequest(executions.map(({ entry }) => entry));
|
|
844
|
+
phase("quoting", session, signal);
|
|
569
845
|
const response = await fetchRelayr(`${RELAYR_API}/v1/bundle/prepaid`, {
|
|
570
846
|
method: "POST",
|
|
571
847
|
headers: { "content-type": "application/json" },
|
|
@@ -594,14 +870,18 @@ export function createSafeRelayrController(options) {
|
|
|
594
870
|
return { state: "ready", session, payments, resumed: false };
|
|
595
871
|
});
|
|
596
872
|
}
|
|
597
|
-
async function check({ account, sessionId, }) {
|
|
598
|
-
return store.withLock(account, async () =>
|
|
873
|
+
async function check({ account, sessionId, signal, }) {
|
|
874
|
+
return store.withLock(account, async () => {
|
|
875
|
+
signal?.throwIfAborted();
|
|
876
|
+
const session = await load(account, sessionId);
|
|
877
|
+
return inspect(session, signal);
|
|
878
|
+
});
|
|
599
879
|
}
|
|
600
880
|
async function fund({ account, sessionId, paymentChainId, signal, onStatus, }) {
|
|
601
881
|
return store.withLock(account, async () => {
|
|
602
882
|
checkAccount(account, signal);
|
|
603
883
|
let session = await load(account, sessionId);
|
|
604
|
-
const checked = await inspect(session);
|
|
884
|
+
const checked = await inspect(session, signal);
|
|
605
885
|
session = checked.session;
|
|
606
886
|
if (checked.state !== "ready")
|
|
607
887
|
throw new SafeRelayrRecoveryError(session, checked.recovery?.message, checked.recovery);
|
|
@@ -612,6 +892,7 @@ export function createSafeRelayrController(options) {
|
|
|
612
892
|
throw new Error("Choose a current payment option from this saved quote.");
|
|
613
893
|
await assertUnreserved(account, session.executions, session.id);
|
|
614
894
|
if (!reviewedIntents.has(reviewKey(session))) {
|
|
895
|
+
phase("reviewing", session, signal);
|
|
615
896
|
await options.review(session.executions, { resumed: true });
|
|
616
897
|
checkAccount(account, signal);
|
|
617
898
|
rememberReview(session);
|
|
@@ -659,6 +940,7 @@ export function createSafeRelayrController(options) {
|
|
|
659
940
|
throw error;
|
|
660
941
|
}
|
|
661
942
|
sending = true;
|
|
943
|
+
phase("payment-submitting", session, signal);
|
|
662
944
|
};
|
|
663
945
|
const onSent = async (payments) => {
|
|
664
946
|
const snapshot = relayrSentPaymentsSnapshot(payments);
|
|
@@ -696,8 +978,10 @@ export function createSafeRelayrController(options) {
|
|
|
696
978
|
paymentStatus: "submitted",
|
|
697
979
|
};
|
|
698
980
|
await store.save(session);
|
|
981
|
+
phase("payment-confirming", session, signal);
|
|
699
982
|
};
|
|
700
983
|
try {
|
|
984
|
+
phase("payment-review", session, signal);
|
|
701
985
|
const sent = await options.sendPayment({
|
|
702
986
|
session: structuredClone(session),
|
|
703
987
|
payment,
|
|
@@ -718,10 +1002,14 @@ export function createSafeRelayrController(options) {
|
|
|
718
1002
|
paymentStatus: checked.session.paymentStatus,
|
|
719
1003
|
};
|
|
720
1004
|
await store.save(session);
|
|
1005
|
+
throw error;
|
|
721
1006
|
}
|
|
1007
|
+
if (sending ||
|
|
1008
|
+
session.payments.length > checked.session.payments.length)
|
|
1009
|
+
throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
|
|
722
1010
|
throw error;
|
|
723
1011
|
}
|
|
724
|
-
return inspect(session);
|
|
1012
|
+
return inspect(session, signal);
|
|
725
1013
|
});
|
|
726
1014
|
}
|
|
727
1015
|
/** Read-only progress polling; each read releases its lock before the wait. */
|
|
@@ -734,7 +1022,7 @@ export function createSafeRelayrController(options) {
|
|
|
734
1022
|
const started = Date.now();
|
|
735
1023
|
while (true) {
|
|
736
1024
|
signal?.throwIfAborted();
|
|
737
|
-
const next = await check({ account, sessionId });
|
|
1025
|
+
const next = await check({ account, sessionId, signal });
|
|
738
1026
|
signal?.throwIfAborted();
|
|
739
1027
|
await onUpdate?.(next);
|
|
740
1028
|
const remaining = timeoutMs - (Date.now() - started);
|