@bananapus/nana-sdk-core 2.22.0 → 2.24.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.
@@ -0,0 +1,635 @@
1
+ import { decodeFunctionData, encodeFunctionData, isAddress, keccak256, toHex, parseAbi, zeroAddress, } from "viem";
2
+ import { SAFE_EXEC_ABI, canonicalSafeTxHash, requireSafeExecutionSuccess, safeExecutionResult, } from "../safeService.js";
3
+ import { isDefiniteWalletRejection } from "./contractWrite.js";
4
+ import { RELAYR_API, RELAYR_UUID_RE, MAX_RELAYR_SENT_PAYMENTS, bindRelayrQuote, readRelayrBundle, relayrBundleRequest, relayrDeadlinePassed, relayrPaymentOptions, relayrPaymentDetails, relayrRetryOption, relayrSentPaymentsSnapshot, relayrQuotedOptions, requireRelayrBundleUnpaid, requireRelayrRetry, revertedRelayrQuote, verifyRelayrDestinations, verifyRelayrPayment, RelayrPaymentRevertedError, } from "./relayr.js";
5
+ export class SafeRelayrRecoveryError extends Error {
6
+ constructor(session, message = "This Safe execution has an existing Relayr bundle. Check the existing bundle before submitting another payment.") {
7
+ super(message);
8
+ this.session = session;
9
+ this.name = "SafeRelayrRecoveryError";
10
+ }
11
+ }
12
+ // Review is a page-lifetime capability, never a local-storage claim. Multiple
13
+ // hook instances can share it; reloading, changing exact bytes, or eviction
14
+ // requires explicit review again. Eviction only adds a review, never a write.
15
+ const reviewedIntents = new Set();
16
+ function reviewKey(session) {
17
+ return keccak256(toHex(JSON.stringify([
18
+ session.account.toLowerCase(),
19
+ session.id,
20
+ session.bundleUuid,
21
+ session.executions.map(({ safe, safeTxHash, nonce, entry }) => [
22
+ entry.chain,
23
+ safe.toLowerCase(),
24
+ safeTxHash.toLowerCase(),
25
+ nonce,
26
+ entry.target.toLowerCase(),
27
+ entry.data.toLowerCase(),
28
+ entry.value,
29
+ ]),
30
+ ])));
31
+ }
32
+ function rememberReview(session) {
33
+ reviewedIntents.add(reviewKey(session));
34
+ if (reviewedIntents.size > 256)
35
+ reviewedIntents.delete(reviewedIntents.values().next().value);
36
+ }
37
+ const SAFE_STATE_ABI = parseAbi([
38
+ "function nonce() view returns (uint256)",
39
+ "function getTransactionHash(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 _nonce) view returns (bytes32)",
40
+ ]);
41
+ /** A nonce reserves its Safe on its chain even when signature bytes or the proposed intent change. */
42
+ export function safeRelayrReservationKey(execution) {
43
+ return `${execution.entry.chain}:${execution.safe.toLowerCase()}:${execution.nonce}`;
44
+ }
45
+ function intentKey(execution) {
46
+ return `${safeRelayrReservationKey(execution)}:${execution.safeTxHash.toLowerCase()}`;
47
+ }
48
+ /** Reuse requires the complete one-to-one intent set. Subsets and supersets conflict. */
49
+ export function sameSafeRelayrIntents(left, right) {
50
+ const keys = left.map(intentKey);
51
+ return (!!left.length &&
52
+ left.length === right.length &&
53
+ new Set(keys).size === keys.length &&
54
+ new Set(right.map(intentKey)).size === right.length &&
55
+ right.every((execution) => keys.includes(intentKey(execution))));
56
+ }
57
+ /** Authenticate execTransaction's signed fields against the expected Safe EIP-712 hash. */
58
+ export function requireSafeRelayrExecution(execution) {
59
+ const { entry, safe, nonce, safeTxHash } = execution;
60
+ if (!Number.isSafeInteger(entry.chain) ||
61
+ entry.chain <= 0 ||
62
+ !Number.isSafeInteger(nonce) ||
63
+ nonce < 0 ||
64
+ !isAddress(safe) ||
65
+ entry.target.toLowerCase() !== safe.toLowerCase() ||
66
+ BigInt(entry.value) !== 0n) {
67
+ throw new Error("The Relayr entry does not match its Safe execution.");
68
+ }
69
+ const decoded = decodeFunctionData({ abi: SAFE_EXEC_ABI, data: entry.data });
70
+ if (decoded.functionName !== "execTransaction")
71
+ throw new Error("The Relayr entry is not a Safe execution.");
72
+ const [to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver,] = decoded.args;
73
+ if (operation !== 0 && operation !== 1)
74
+ throw new Error("The Safe execution has an invalid operation.");
75
+ if (encodeFunctionData({
76
+ abi: SAFE_EXEC_ABI,
77
+ functionName: "execTransaction",
78
+ args: decoded.args,
79
+ }).toLowerCase() !== entry.data.toLowerCase())
80
+ throw new Error("The Safe execution calldata is not canonical.");
81
+ if (gasPrice !== 0n ||
82
+ gasToken.toLowerCase() !== zeroAddress ||
83
+ refundReceiver.toLowerCase() !== zeroAddress)
84
+ throw new Error("Relayr Safe executions must not reimburse an executor from Safe funds.");
85
+ const message = {
86
+ to,
87
+ value,
88
+ data,
89
+ operation,
90
+ safeTxGas,
91
+ baseGas,
92
+ gasPrice,
93
+ gasToken,
94
+ refundReceiver,
95
+ nonce: BigInt(nonce),
96
+ };
97
+ const hash = canonicalSafeTxHash(entry.chain, safe, { ...message, nonce });
98
+ if (hash.toLowerCase() !== safeTxHash.toLowerCase())
99
+ throw new Error("The Safe execution does not match its reviewed transaction hash.");
100
+ return message;
101
+ }
102
+ /** Exact live nonce/hash guards for adapters that perform eth_call preconditions. */
103
+ export function safeRelayrPreconditions(execution) {
104
+ const tx = requireSafeRelayrExecution(execution);
105
+ return [
106
+ {
107
+ address: execution.safe,
108
+ data: encodeFunctionData({ abi: SAFE_STATE_ABI, functionName: "nonce" }),
109
+ expected: `0x${tx.nonce.toString(16).padStart(64, "0")}`,
110
+ },
111
+ {
112
+ address: execution.safe,
113
+ data: encodeFunctionData({
114
+ abi: SAFE_STATE_ABI,
115
+ functionName: "getTransactionHash",
116
+ args: [
117
+ tx.to,
118
+ tx.value,
119
+ tx.data,
120
+ tx.operation,
121
+ tx.safeTxGas,
122
+ tx.baseGas,
123
+ tx.gasPrice,
124
+ tx.gasToken,
125
+ tx.refundReceiver,
126
+ tx.nonce,
127
+ ],
128
+ }),
129
+ expected: execution.safeTxHash,
130
+ },
131
+ ];
132
+ }
133
+ function validateExecutions(executions) {
134
+ if (!executions.length)
135
+ throw new Error("Select at least one Safe execution.");
136
+ executions.forEach(requireSafeRelayrExecution);
137
+ const keys = executions.map(safeRelayrReservationKey);
138
+ if (new Set(keys).size !== keys.length)
139
+ throw new Error("A Safe nonce may appear only once in a Relayr bundle.");
140
+ }
141
+ function entriesEqual(left, right) {
142
+ return (left.chain === right.chain &&
143
+ left.target.toLowerCase() === right.target.toLowerCase() &&
144
+ left.data.toLowerCase() === right.data.toLowerCase() &&
145
+ BigInt(left.value) === BigInt(right.value) &&
146
+ (left.virtual_nonce ?? 0) === (right.virtual_nonce ?? 0));
147
+ }
148
+ /** Authenticate every status UUID, chain, and (when supplied) exact request, even before a hash exists. */
149
+ function requireSafeRelayrRecords(bindings, records) {
150
+ if (records.length !== bindings.length ||
151
+ new Set(records.map((record) => record.tx_uuid?.toLowerCase())).size !==
152
+ bindings.length)
153
+ throw new Error("Relayr's records do not identify every saved Safe execution exactly once.");
154
+ for (const binding of bindings) {
155
+ const record = records.find((item) => item.tx_uuid?.toLowerCase() === binding.txUuid.toLowerCase());
156
+ if (!record ||
157
+ (record.chain ?? record.request?.chain) !== binding.chain ||
158
+ (record.request !== undefined &&
159
+ !entriesEqual(record.request, binding.entry)))
160
+ throw new Error("Relayr's record does not match the saved Safe transaction binding.");
161
+ }
162
+ }
163
+ /** Canonical exact destination receipts plus matching, refund-free Safe success events. */
164
+ export async function verifySafeRelayrLanding(clientFor, { executions, bindings, records, }) {
165
+ validateExecutions(executions);
166
+ const entries = relayrBundleRequest(executions.map(({ entry }) => entry)).transactions;
167
+ if (bindings.length !== entries.length ||
168
+ bindings.some((binding, index) => !entriesEqual(binding.entry, entries[index])))
169
+ throw new Error("The saved Safe bindings do not match the reviewed executions.");
170
+ requireSafeRelayrRecords(bindings, records);
171
+ const verified = await verifyRelayrDestinations(clientFor, {
172
+ bindings,
173
+ records,
174
+ });
175
+ verified.forEach(({ receipt }, index) => {
176
+ const { safe, safeTxHash } = executions[index];
177
+ requireSafeExecutionSuccess(receipt, safe, safeTxHash);
178
+ const event = safeExecutionResult(receipt, safe, safeTxHash);
179
+ if (event.status !== "success" || event.payment !== 0n)
180
+ throw new Error("The Safe execution reimbursed its executor. Keep the existing bundle for verification.");
181
+ });
182
+ return verified;
183
+ }
184
+ /** Validate persisted bindings again; local storage is not authenticated evidence. */
185
+ function requireSessionQuote(session) {
186
+ validateExecutions(session.executions);
187
+ const quote = session.quote;
188
+ const entries = relayrBundleRequest(session.executions.map(({ entry }) => entry)).transactions;
189
+ if (!quote ||
190
+ !RELAYR_UUID_RE.test(quote.bundle_uuid) ||
191
+ quote.bundle_uuid !== session.bundleUuid ||
192
+ !Array.isArray(quote.expectedTransactions) ||
193
+ quote.expectedTransactions.some(({ txUuid }) => !RELAYR_UUID_RE.test(txUuid)) ||
194
+ quote.expectedTransactions.length !== entries.length ||
195
+ new Set(quote.expectedTransactions.map(({ txUuid }) => txUuid)).size !==
196
+ entries.length ||
197
+ quote.expectedTransactions.some((binding, index) => binding.chain !== entries[index].chain ||
198
+ !entriesEqual(binding.entry, entries[index]))) {
199
+ throw new SafeRelayrRecoveryError(session, "The saved Safe bundle lacks exact transaction bindings. Keep it saved and verify its existing transactions.");
200
+ }
201
+ return quote;
202
+ }
203
+ /** Shared Safe Relayr state machine. It never signs Safe proposals or submits on prepare/check. */
204
+ export function createSafeRelayrController(options) {
205
+ const { store } = options;
206
+ const fetchRelayr = options.fetch ?? globalThis.fetch;
207
+ const checkAccount = (account, signal) => {
208
+ signal?.throwIfAborted();
209
+ if (!isAddress(account) ||
210
+ options.currentAccount()?.toLowerCase() !== account.toLowerCase())
211
+ throw new Error("The connected account changed. Review again.");
212
+ };
213
+ const load = async (account, id) => {
214
+ const session = (await store.list(account)).find((candidate) => candidate.id === id);
215
+ if (!session || session.account.toLowerCase() !== account.toLowerCase())
216
+ throw new Error("The saved Safe execution belongs to another account or is unavailable.");
217
+ return session;
218
+ };
219
+ const revalidate = async (executions, account, status, again = false, signal) => {
220
+ validateExecutions(executions);
221
+ checkAccount(account, signal);
222
+ // Launch every independent check. The RPC transport paces egress without
223
+ // holding the next chain behind a slow response. Drain failures before exit.
224
+ const settled = await Promise.allSettled(executions.map(async (execution, index) => {
225
+ status?.(index, again ? "rechecking" : "checking", execution);
226
+ try {
227
+ const original = JSON.stringify([
228
+ execution.entry,
229
+ execution.safe,
230
+ execution.safeTxHash,
231
+ execution.nonce,
232
+ ]);
233
+ await options.revalidate(execution, account);
234
+ if (JSON.stringify([
235
+ execution.entry,
236
+ execution.safe,
237
+ execution.safeTxHash,
238
+ execution.nonce,
239
+ ]) !== original)
240
+ throw new Error("A Safe recheck changed its frozen execution.");
241
+ checkAccount(account, signal);
242
+ status?.(index, "ready", execution);
243
+ }
244
+ catch (error) {
245
+ status?.(index, "failed", execution);
246
+ throw error;
247
+ }
248
+ }));
249
+ const failure = settled.find((item) => item.status === "rejected");
250
+ if (failure?.status === "rejected")
251
+ throw failure.reason;
252
+ checkAccount(account, signal);
253
+ };
254
+ const overlap = (session, executions) => {
255
+ const keys = new Set([
256
+ ...session.executions.map(safeRelayrReservationKey),
257
+ ...(session.reservationKeys ?? []).map((key) => key.toLowerCase()),
258
+ ]);
259
+ return executions.some((execution) => keys.has(safeRelayrReservationKey(execution)) ||
260
+ keys.has(`${execution.entry.chain}:${execution.safe.toLowerCase()}:*`));
261
+ };
262
+ const unidentified = (session) => !session.executions.length && !session.reservationKeys?.length;
263
+ const unresolved = (session) => session.state !== "complete" && session.state !== "released";
264
+ const assertUnreserved = async (account, executions, exceptId) => {
265
+ const conflict = (await store.list(account)).find((session) => session.id !== exceptId &&
266
+ unresolved(session) &&
267
+ (unidentified(session) || overlap(session, executions)));
268
+ if (conflict)
269
+ throw new SafeRelayrRecoveryError(conflict);
270
+ };
271
+ const result = (state, session, payments = []) => ({ state, session, payments });
272
+ async function inspect(session) {
273
+ if (session.state === "released")
274
+ return result("released", session);
275
+ if (session.state === "complete")
276
+ return result("complete", session);
277
+ if (!session.bundleUuid || !session.quote)
278
+ return result("pending", session);
279
+ const quote = requireSessionQuote(session);
280
+ const chains = session.executions.map(({ entry }) => entry.chain);
281
+ const bundle = await readRelayrBundle(session.bundleUuid, {
282
+ fetch: fetchRelayr,
283
+ });
284
+ if (!Array.isArray(bundle.transactions))
285
+ throw new SafeRelayrRecoveryError(session, "Relayr has not returned this bundle's transactions. Check the existing bundle again.");
286
+ const records = bundle.transactions;
287
+ requireSafeRelayrRecords(quote.expectedTransactions, records);
288
+ session = { ...session, records };
289
+ await store.save(session);
290
+ // A reported hash is only a pointer. Completion requires canonical exact
291
+ // calldata/value receipts AND the matching Safe ExecutionSuccess event.
292
+ if (records.length &&
293
+ records.every((record) => record.status?.data?.hash || record.status?.data?.transaction?.hash)) {
294
+ const verified = await verifySafeRelayrLanding(options.clientFor, {
295
+ executions: session.executions,
296
+ bindings: quote.expectedTransactions,
297
+ records,
298
+ });
299
+ await Promise.all(session.executions.map((execution, index) => options.afterVerified?.(execution, verified[index])));
300
+ session = { ...session, state: "complete" };
301
+ await store.save(session);
302
+ return result("complete", session);
303
+ }
304
+ if (session.paymentStatus === "sending")
305
+ return result("pending", session);
306
+ if (session.payments.some((payment) => payment.bundleUuid !== session.bundleUuid))
307
+ throw new SafeRelayrRecoveryError(session, "A saved payment belongs to another bundle. Keep the existing bundle for verification.");
308
+ if (session.payments.length) {
309
+ let everyReverted = true;
310
+ for (const payment of session.payments) {
311
+ const client = options.clientFor(payment.chainId);
312
+ if (!client)
313
+ return result("pending", session);
314
+ try {
315
+ await verifyRelayrPayment(client, {
316
+ hash: payment.hash,
317
+ from: session.account,
318
+ payment,
319
+ });
320
+ session = { ...session, paymentStatus: "confirmed" };
321
+ await store.save(session);
322
+ return result("pending", session);
323
+ }
324
+ catch (error) {
325
+ if (!(error instanceof RelayrPaymentRevertedError))
326
+ everyReverted = false;
327
+ }
328
+ }
329
+ if (!everyReverted)
330
+ return result("pending", session);
331
+ session = { ...session, paymentStatus: "reverted" };
332
+ await store.save(session);
333
+ const disposition = await revertedRelayrQuote(options.clientFor, {
334
+ bundleUuid: quote.bundle_uuid,
335
+ payments: session.payments,
336
+ options: quote.payment_info,
337
+ destinationChainIds: chains,
338
+ account: session.account,
339
+ }, { fetch: fetchRelayr });
340
+ if (disposition.state === "funded")
341
+ return result("pending", session);
342
+ if (disposition.state === "released") {
343
+ session = { ...session, state: "released", paymentStatus: "expired" };
344
+ await store.save(session);
345
+ return result("released", session);
346
+ }
347
+ }
348
+ else if (session.paymentStatus !== "unfunded") {
349
+ // A wallet invocation without a known hash may still have broadcast.
350
+ return result("pending", session);
351
+ }
352
+ const quoted = relayrQuotedOptions(quote, chains);
353
+ if (!quoted.length)
354
+ return result("pending", session);
355
+ const payments = relayrPaymentOptions(quote, chains);
356
+ const expired = payments.length
357
+ ? [false]
358
+ : await Promise.all(quoted.map(async ({ details }) => {
359
+ const client = options.clientFor(details.chainId);
360
+ return (!!client &&
361
+ (await relayrDeadlinePassed(client, details.deadline).catch(() => false)));
362
+ }));
363
+ if (expired.every(Boolean)) {
364
+ try {
365
+ await requireRelayrBundleUnpaid(quote.bundle_uuid, {
366
+ fetch: fetchRelayr,
367
+ });
368
+ }
369
+ catch {
370
+ return result("pending", session);
371
+ }
372
+ session = { ...session, state: "released", paymentStatus: "expired" };
373
+ await store.save(session);
374
+ return result("released", session);
375
+ }
376
+ try {
377
+ await requireRelayrBundleUnpaid(quote.bundle_uuid, {
378
+ fetch: fetchRelayr,
379
+ });
380
+ }
381
+ catch {
382
+ return result("pending", session);
383
+ }
384
+ const allowed = session.payments.length && payments.length
385
+ ? [relayrRetryOption(session.payments, payments)]
386
+ : payments;
387
+ return allowed.length
388
+ ? result("ready", session, allowed)
389
+ : result("pending", session);
390
+ }
391
+ async function prepare({ account, executions: input, signal, onStatus, }) {
392
+ const executions = structuredClone([...input]);
393
+ validateExecutions(executions);
394
+ return store.withLock(account, async () => {
395
+ checkAccount(account, signal);
396
+ const reservations = (await store.list(account)).filter((session) => unresolved(session) &&
397
+ (unidentified(session) || overlap(session, executions)));
398
+ for (const saved of reservations) {
399
+ let checked;
400
+ try {
401
+ checked = await inspect(saved);
402
+ }
403
+ catch (error) {
404
+ throw new SafeRelayrRecoveryError(saved, error instanceof Error ? error.message : undefined);
405
+ }
406
+ if (checked.state === "released" || checked.state === "complete")
407
+ continue;
408
+ if (checked.state !== "ready" ||
409
+ saved.account.toLowerCase() !== account.toLowerCase() ||
410
+ !sameSafeRelayrIntents(saved.executions, executions))
411
+ throw new SafeRelayrRecoveryError(checked.session);
412
+ await assertUnreserved(account, executions, saved.id);
413
+ await revalidate(saved.executions, account, onStatus, false, signal);
414
+ await options.review(saved.executions, { resumed: true });
415
+ checkAccount(account, signal);
416
+ rememberReview(checked.session);
417
+ return { ...checked, state: "ready", resumed: true };
418
+ }
419
+ await revalidate(executions, account, onStatus, false, signal);
420
+ await options.review(executions, { resumed: false });
421
+ checkAccount(account, signal);
422
+ await assertUnreserved(account, executions);
423
+ let session = {
424
+ id: options.createId?.() ?? globalThis.crypto.randomUUID(),
425
+ account,
426
+ executions,
427
+ state: "publishing",
428
+ paymentStatus: "unfunded",
429
+ payments: [],
430
+ createdAt: Date.now(),
431
+ };
432
+ // Persist before POST. An interrupted response may have published the Safe
433
+ // signatures; loss of the bundle ID is never permission to publish twice.
434
+ await store.save(session);
435
+ try {
436
+ checkAccount(account, signal);
437
+ }
438
+ catch (error) {
439
+ await store.save({ ...session, state: "released" });
440
+ throw error;
441
+ }
442
+ const request = relayrBundleRequest(executions.map(({ entry }) => entry));
443
+ const response = await fetchRelayr(`${RELAYR_API}/v1/bundle/prepaid`, {
444
+ method: "POST",
445
+ headers: { "content-type": "application/json" },
446
+ body: JSON.stringify(request),
447
+ cache: "no-store",
448
+ // UI cancellation stops its updates, but must not throw away a POST
449
+ // response that can identify published signatures. Bound the network
450
+ // request independently so a stalled server cannot hold the lock forever.
451
+ signal: AbortSignal.timeout(45_000),
452
+ });
453
+ const quote = await bindRelayrQuote(response, request, {
454
+ fetch: fetchRelayr,
455
+ });
456
+ session = {
457
+ ...session,
458
+ quote,
459
+ bundleUuid: quote.bundle_uuid,
460
+ state: "active",
461
+ };
462
+ await store.save(session);
463
+ checkAccount(account, signal);
464
+ const payments = relayrPaymentOptions(quote, executions.map(({ entry }) => entry.chain));
465
+ if (!payments.length)
466
+ throw new SafeRelayrRecoveryError(session, "The saved Relayr quote has no payable option. Check the existing bundle.");
467
+ rememberReview(session);
468
+ return { state: "ready", session, payments, resumed: false };
469
+ });
470
+ }
471
+ async function check({ account, sessionId, }) {
472
+ return store.withLock(account, async () => inspect(await load(account, sessionId)));
473
+ }
474
+ async function fund({ account, sessionId, paymentChainId, signal, onStatus, }) {
475
+ return store.withLock(account, async () => {
476
+ checkAccount(account, signal);
477
+ let session = await load(account, sessionId);
478
+ const checked = await inspect(session);
479
+ session = checked.session;
480
+ if (checked.state !== "ready")
481
+ throw new SafeRelayrRecoveryError(session);
482
+ if (session.payments.length >= MAX_RELAYR_SENT_PAYMENTS)
483
+ throw new SafeRelayrRecoveryError(session, "This quote has reached its saved payment attempt limit. Check the existing bundle.");
484
+ const payment = checked.payments.find((option) => option.chain === paymentChainId);
485
+ if (!payment)
486
+ throw new Error("Choose a current payment option from this saved quote.");
487
+ await assertUnreserved(account, session.executions, session.id);
488
+ if (!reviewedIntents.has(reviewKey(session))) {
489
+ await options.review(session.executions, { resumed: true });
490
+ checkAccount(account, signal);
491
+ rememberReview(session);
492
+ }
493
+ let verified = false;
494
+ let sending = false;
495
+ const requireOpenPayment = () => relayrPaymentDetails(payment, {
496
+ bundleUuid: session.bundleUuid,
497
+ destinationChainIds: session.executions.map(({ entry }) => entry.chain),
498
+ });
499
+ const beforeSend = async () => {
500
+ await revalidate(session.executions, account, onStatus, true, signal);
501
+ await assertUnreserved(account, session.executions, session.id);
502
+ if (session.payments.length) {
503
+ await requireRelayrRetry(options.clientFor, {
504
+ payments: session.payments,
505
+ from: account,
506
+ bundleUuid: session.bundleUuid,
507
+ }, { fetch: fetchRelayr });
508
+ }
509
+ else
510
+ await requireRelayrBundleUnpaid(session.bundleUuid, {
511
+ fetch: fetchRelayr,
512
+ });
513
+ checkAccount(account, signal);
514
+ requireOpenPayment();
515
+ verified = true;
516
+ };
517
+ const onSending = async () => {
518
+ checkAccount(account, signal);
519
+ if (!verified || sending)
520
+ throw new Error("Recheck the Safe execution before funding it.");
521
+ session = { ...session, paymentStatus: "sending" };
522
+ await store.save(session);
523
+ try {
524
+ checkAccount(account, signal);
525
+ requireOpenPayment();
526
+ }
527
+ catch (error) {
528
+ session = {
529
+ ...session,
530
+ paymentStatus: checked.session.paymentStatus,
531
+ };
532
+ await store.save(session);
533
+ throw error;
534
+ }
535
+ sending = true;
536
+ };
537
+ const onSent = async (payments) => {
538
+ const snapshot = relayrSentPaymentsSnapshot(payments);
539
+ if (!sending ||
540
+ !snapshot?.length ||
541
+ snapshot.some((item) => item.bundleUuid !== session.bundleUuid))
542
+ throw new Error("The payment does not match the saved Safe bundle.");
543
+ if (!snapshot.some((item) => !checked.session.payments.some((prior) => prior.hash.toLowerCase() === item.hash.toLowerCase())))
544
+ throw new Error("The new payment attempt has no new transaction hash.");
545
+ for (const prior of session.payments) {
546
+ if (!snapshot.some((item) => item.hash.toLowerCase() === prior.hash.toLowerCase() &&
547
+ item.chainId === prior.chainId &&
548
+ item.calldata.toLowerCase() === prior.calldata.toLowerCase() &&
549
+ item.amount === prior.amount &&
550
+ item.deadline === prior.deadline &&
551
+ item.target.toLowerCase() === prior.target.toLowerCase()))
552
+ throw new Error("The saved payment history must retain every known payment.");
553
+ }
554
+ const details = relayrPaymentDetails(payment, {
555
+ bundleUuid: session.bundleUuid,
556
+ destinationChainIds: session.executions.map(({ entry }) => entry.chain),
557
+ nowSeconds: 0,
558
+ });
559
+ for (const item of snapshot.filter((item) => !session.payments.some((prior) => prior.hash === item.hash))) {
560
+ if (item.chainId !== details.chainId ||
561
+ BigInt(item.amount) !== details.amount ||
562
+ BigInt(item.deadline) !== details.deadline ||
563
+ item.calldata.toLowerCase() !== details.calldata.toLowerCase() ||
564
+ item.target.toLowerCase() !== details.target.toLowerCase())
565
+ throw new Error("The funding payment differs from the selected Relayr option.");
566
+ }
567
+ session = {
568
+ ...session,
569
+ payments: structuredClone(payments),
570
+ paymentStatus: "submitted",
571
+ };
572
+ await store.save(session);
573
+ };
574
+ try {
575
+ const sent = await options.sendPayment({
576
+ session: structuredClone(session),
577
+ payment,
578
+ beforeSend,
579
+ onSending,
580
+ onSent,
581
+ });
582
+ if (!sent.payments.some((item) => item.hash.toLowerCase() === sent.hash.toLowerCase()))
583
+ throw new Error("The funding result omitted its transaction hash.");
584
+ await onSent(sent.payments);
585
+ }
586
+ catch (error) {
587
+ if (sending &&
588
+ isDefiniteWalletRejection(error) &&
589
+ session.payments.length === checked.session.payments.length) {
590
+ session = {
591
+ ...session,
592
+ paymentStatus: checked.session.paymentStatus,
593
+ };
594
+ await store.save(session);
595
+ }
596
+ throw error;
597
+ }
598
+ return inspect(session);
599
+ });
600
+ }
601
+ /** Read-only progress polling; each read releases its lock before the wait. */
602
+ async function watch({ account, sessionId, signal, onUpdate, intervalMs = 2_500, timeoutMs = 300_000, }) {
603
+ if (!Number.isFinite(intervalMs) ||
604
+ intervalMs <= 0 ||
605
+ !Number.isFinite(timeoutMs) ||
606
+ timeoutMs < 0)
607
+ throw new Error("Safe Relayr polling needs a positive interval and a finite timeout.");
608
+ const started = Date.now();
609
+ while (true) {
610
+ signal?.throwIfAborted();
611
+ const next = await check({ account, sessionId });
612
+ signal?.throwIfAborted();
613
+ await onUpdate?.(next);
614
+ const remaining = timeoutMs - (Date.now() - started);
615
+ if (next.state !== "pending" || remaining <= 0)
616
+ return next;
617
+ await new Promise((resolve, reject) => {
618
+ const aborted = () => {
619
+ clearTimeout(timer);
620
+ signal?.removeEventListener("abort", aborted);
621
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
622
+ };
623
+ const timer = setTimeout(() => {
624
+ signal?.removeEventListener("abort", aborted);
625
+ resolve();
626
+ }, Math.min(intervalMs, remaining));
627
+ signal?.addEventListener("abort", aborted, { once: true });
628
+ if (signal?.aborted)
629
+ aborted();
630
+ });
631
+ }
632
+ }
633
+ return { prepare, check, fund, watch };
634
+ }
635
+ //# sourceMappingURL=safeRelayr.js.map