@bananapus/nana-sdk-core 6.2.0 → 6.3.1

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.
Files changed (111) hide show
  1. package/dist/cjs/review/confirming.d.ts +8 -0
  2. package/dist/cjs/review/confirming.d.ts.map +1 -0
  3. package/dist/cjs/review/confirming.js +11 -0
  4. package/dist/cjs/review/confirming.js.map +1 -0
  5. package/dist/cjs/review/locks.d.ts +87 -7
  6. package/dist/cjs/review/locks.d.ts.map +1 -1
  7. package/dist/cjs/review/locks.js +328 -79
  8. package/dist/cjs/review/locks.js.map +1 -1
  9. package/dist/cjs/review/messages.d.ts +16 -0
  10. package/dist/cjs/review/messages.d.ts.map +1 -1
  11. package/dist/cjs/review/messages.js +17 -1
  12. package/dist/cjs/review/messages.js.map +1 -1
  13. package/dist/cjs/review/react/ownership.d.ts +30 -0
  14. package/dist/cjs/review/react/ownership.d.ts.map +1 -0
  15. package/dist/cjs/review/react/ownership.js +27 -0
  16. package/dist/cjs/review/react/ownership.js.map +1 -0
  17. package/dist/cjs/review/react/useReviewedRun.d.ts +3 -1
  18. package/dist/cjs/review/react/useReviewedRun.d.ts.map +1 -1
  19. package/dist/cjs/review/react/useReviewedRun.js +428 -50
  20. package/dist/cjs/review/react/useReviewedRun.js.map +1 -1
  21. package/dist/cjs/review/relayrSend.d.ts +16 -1
  22. package/dist/cjs/review/relayrSend.d.ts.map +1 -1
  23. package/dist/cjs/review/relayrSend.js +25 -2
  24. package/dist/cjs/review/relayrSend.js.map +1 -1
  25. package/dist/cjs/review/safeRelayr.d.ts +2 -1
  26. package/dist/cjs/review/safeRelayr.d.ts.map +1 -1
  27. package/dist/cjs/review/safeRelayr.js +23 -29
  28. package/dist/cjs/review/safeRelayr.js.map +1 -1
  29. package/dist/cjs/review/send.d.ts +31 -12
  30. package/dist/cjs/review/send.d.ts.map +1 -1
  31. package/dist/cjs/review/send.js +191 -41
  32. package/dist/cjs/review/send.js.map +1 -1
  33. package/dist/cjs/review/sequence.d.ts +25 -0
  34. package/dist/cjs/review/sequence.d.ts.map +1 -1
  35. package/dist/cjs/review/sequence.js +45 -7
  36. package/dist/cjs/review/sequence.js.map +1 -1
  37. package/dist/cjs/review/tabState.d.ts +4 -0
  38. package/dist/cjs/review/tabState.d.ts.map +1 -1
  39. package/dist/cjs/review/tabState.js +4 -0
  40. package/dist/cjs/review/tabState.js.map +1 -1
  41. package/dist/cjs/review/wallet.d.ts.map +1 -1
  42. package/dist/cjs/review/wallet.js +3 -1
  43. package/dist/cjs/review/wallet.js.map +1 -1
  44. package/dist/cjs/review/writeHold.d.ts +74 -11
  45. package/dist/cjs/review/writeHold.d.ts.map +1 -1
  46. package/dist/cjs/review/writeHold.js +65 -16
  47. package/dist/cjs/review/writeHold.js.map +1 -1
  48. package/dist/cjs/review/writeRecovery.d.ts +31 -6
  49. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  50. package/dist/cjs/review/writeRecovery.js +120 -7
  51. package/dist/cjs/review/writeRecovery.js.map +1 -1
  52. package/dist/cjs/testing.d.ts +13 -0
  53. package/dist/cjs/testing.d.ts.map +1 -1
  54. package/dist/cjs/testing.js +21 -0
  55. package/dist/cjs/testing.js.map +1 -1
  56. package/dist/esm/review/confirming.d.ts +8 -0
  57. package/dist/esm/review/confirming.d.ts.map +1 -0
  58. package/dist/esm/review/confirming.js +8 -0
  59. package/dist/esm/review/confirming.js.map +1 -0
  60. package/dist/esm/review/locks.d.ts +87 -7
  61. package/dist/esm/review/locks.d.ts.map +1 -1
  62. package/dist/esm/review/locks.js +321 -78
  63. package/dist/esm/review/locks.js.map +1 -1
  64. package/dist/esm/review/messages.d.ts +16 -0
  65. package/dist/esm/review/messages.d.ts.map +1 -1
  66. package/dist/esm/review/messages.js +16 -0
  67. package/dist/esm/review/messages.js.map +1 -1
  68. package/dist/esm/review/react/ownership.d.ts +30 -0
  69. package/dist/esm/review/react/ownership.d.ts.map +1 -0
  70. package/dist/esm/review/react/ownership.js +24 -0
  71. package/dist/esm/review/react/ownership.js.map +1 -0
  72. package/dist/esm/review/react/useReviewedRun.d.ts +3 -1
  73. package/dist/esm/review/react/useReviewedRun.d.ts.map +1 -1
  74. package/dist/esm/review/react/useReviewedRun.js +429 -51
  75. package/dist/esm/review/react/useReviewedRun.js.map +1 -1
  76. package/dist/esm/review/relayrSend.d.ts +16 -1
  77. package/dist/esm/review/relayrSend.d.ts.map +1 -1
  78. package/dist/esm/review/relayrSend.js +28 -5
  79. package/dist/esm/review/relayrSend.js.map +1 -1
  80. package/dist/esm/review/safeRelayr.d.ts +2 -1
  81. package/dist/esm/review/safeRelayr.d.ts.map +1 -1
  82. package/dist/esm/review/safeRelayr.js +24 -30
  83. package/dist/esm/review/safeRelayr.js.map +1 -1
  84. package/dist/esm/review/send.d.ts +31 -12
  85. package/dist/esm/review/send.d.ts.map +1 -1
  86. package/dist/esm/review/send.js +193 -43
  87. package/dist/esm/review/send.js.map +1 -1
  88. package/dist/esm/review/sequence.d.ts +25 -0
  89. package/dist/esm/review/sequence.d.ts.map +1 -1
  90. package/dist/esm/review/sequence.js +43 -7
  91. package/dist/esm/review/sequence.js.map +1 -1
  92. package/dist/esm/review/tabState.d.ts +4 -0
  93. package/dist/esm/review/tabState.d.ts.map +1 -1
  94. package/dist/esm/review/tabState.js +4 -0
  95. package/dist/esm/review/tabState.js.map +1 -1
  96. package/dist/esm/review/wallet.d.ts.map +1 -1
  97. package/dist/esm/review/wallet.js +3 -1
  98. package/dist/esm/review/wallet.js.map +1 -1
  99. package/dist/esm/review/writeHold.d.ts +74 -11
  100. package/dist/esm/review/writeHold.d.ts.map +1 -1
  101. package/dist/esm/review/writeHold.js +65 -16
  102. package/dist/esm/review/writeHold.js.map +1 -1
  103. package/dist/esm/review/writeRecovery.d.ts +31 -6
  104. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  105. package/dist/esm/review/writeRecovery.js +118 -7
  106. package/dist/esm/review/writeRecovery.js.map +1 -1
  107. package/dist/esm/testing.d.ts +13 -0
  108. package/dist/esm/testing.d.ts.map +1 -1
  109. package/dist/esm/testing.js +20 -0
  110. package/dist/esm/testing.js.map +1 -1
  111. package/package.json +1 -1
@@ -1,8 +1,10 @@
1
1
  import { ReviewLineError } from "../lineError.js";
2
+ import { ConfirmationQueuedError, LockLostError, lockWorkRunning, } from "../locks.js";
2
3
  import { tell } from "../receipt.js";
3
- import { createHeldWriteView, discardReviewedSequence, errorLine, findReviewedSequence, followHeldWrite, isDefiniteWalletRejection, listReviewedWrites, openStepHold, RECEIPT_RETRY_MS, REVIEWED_ACCOUNT_CHANGED_LINE, REVIEWED_ENGINE_LOAD_LINE, ReviewedReceiptUnverifiedError, reviewedRunStatus, safeBatchCall, settledLine, TransactionReviewCancelledError, walletReady, } from "../index.js";
4
+ import { createHeldWriteView, discardReviewedSequence, errorLine, findReviewedSequence, followHeldWrite, HELD_AWAY_SUCCESS_LINE, HELD_SEND_AGAIN_LABEL, isDefiniteWalletRejection, listReviewedWrites, openStepHold, RECEIPT_RETRY_MS, REVIEWED_ACCOUNT_CHANGED_LINE, REVIEWED_ENGINE_LOAD_LINE, ReviewedReceiptUnverifiedError, reviewedRunStatus, safeBatchCall, settledLine, TransactionReviewCancelledError, walletReady, WriteHeldError, WriteHoldUnacknowledgedSuccessError, } from "../index.js";
4
5
  import { useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
5
6
  import { actionsOf, idle, keyOf, none, } from "./heldView.js";
7
+ import { mergeOwn } from "./ownership.js";
6
8
  const IDLE = {
7
9
  cancelled: false,
8
10
  stopped: false,
@@ -18,6 +20,7 @@ const IDLE = {
18
20
  receipt: null,
19
21
  settled: null,
20
22
  acknowledge: null,
23
+ away: null,
21
24
  };
22
25
  /** The bytes a call sends, for comparing two actions. */
23
26
  const sent = ({ chainId, to, data, value, gas }) => [
@@ -27,6 +30,28 @@ const sent = ({ chainId, to, data, value, gas }) => [
27
30
  value ?? 0n,
28
31
  gas ?? null,
29
32
  ];
33
+ /** This form's earlier write of the same calls succeeded: the run closes on it, sending nothing. */
34
+ /**
35
+ * The holds `action`'s steps are held in, each with its step: a resumed
36
+ * sequence's sending step; a Safe app's batch as its one MultiSend
37
+ * proposal, its last step; or each call. The one home for that mapping.
38
+ */
39
+ function heldSteps(account, safe, action, saved) {
40
+ if (saved?.sending)
41
+ return [
42
+ { call: saved.calls[saved.sending.step], step: saved.sending.step },
43
+ ];
44
+ const total = saved?.calls.length ?? action.calls.length;
45
+ return !saved && action.batch && safe && action.calls.length > 1
46
+ ? [{ call: safeBatchCall(account, action.calls), step: total - 1 }]
47
+ : action.calls.map((call, step) => ({ call, step }));
48
+ }
49
+ class OwnSucceeded extends Error {
50
+ constructor(found) {
51
+ super("succeeded");
52
+ this.found = found;
53
+ }
54
+ }
30
55
  export const REVIEWED_RUN_SCOPE_LINE = "This action has more than one step and no scope to resume it by. Nothing was sent.";
31
56
  export const REVIEWED_RUN_BUSY_LINE = "This action is still running. Wait for it to finish, then try again.";
32
57
  /**
@@ -42,16 +67,29 @@ export function useReviewedRun(deps) {
42
67
  if (cancelling)
43
68
  void tell(cancelling.told);
44
69
  }, [cancelling]);
45
- /** The last shown success's acknowledgement: the next run waits for it. */
70
+ /** Every shown success's acknowledgement: the next run waits for all. */
46
71
  const acknowledged = useRef(Promise.resolve());
47
72
  // A success is acknowledged only once its done rendered: a reset after
48
- // that hides nothing, and one before it leaves the success warning.
73
+ // that hides nothing, and one before it leaves the success warning. A
74
+ // later one never drops an earlier one still running from the wait.
49
75
  useEffect(() => {
50
76
  if (state.acknowledge)
51
- acknowledged.current = state.acknowledge();
77
+ acknowledged.current = Promise.all([
78
+ acknowledged.current,
79
+ state.acknowledge(),
80
+ ]).then(() => undefined);
52
81
  }, [state.acknowledge]);
53
82
  /** The hold followed after a run, and its chain's client: its view is `held`. */
54
- const [watched, setWatched] = useState(null);
83
+ const [watched, setWatchedState] = useState(null);
84
+ /**
85
+ * `watched` as last set, for a handler that resets and runs from one
86
+ * render's handle (a "Try again"): never that render's stale copy.
87
+ */
88
+ const watchedNow = useRef(null);
89
+ const setWatched = (next) => {
90
+ watchedNow.current = next;
91
+ setWatchedState(next);
92
+ };
55
93
  /** The latest run: an earlier one's late answers change nothing. */
56
94
  const latest = useRef(0);
57
95
  /** The sent step whose hold the latest run's `ended()` awaits. */
@@ -72,6 +110,101 @@ export function useReviewedRun(deps) {
72
110
  * hold keeps the sent step.
73
111
  */
74
112
  const leave = useRef(() => { });
113
+ /** Every reservation this runner's runs followed: its own, whatever its calls now. */
114
+ const mine = useRef(new Set());
115
+ /**
116
+ * The reservations `calls`' holds hold now (a held write, or a success
117
+ * nothing showed), each with its hold.
118
+ */
119
+ const holdsOf = (account, calls) => calls.flatMap((call) => {
120
+ if (!deps || !account)
121
+ return [];
122
+ try {
123
+ // A hold's key is its call's, whatever the Safe mode.
124
+ const handle = openStepHold(deps.hold, account, false, call);
125
+ const held = handle.peek();
126
+ const reservation = held.status !== "clear"
127
+ ? held.reservation
128
+ : handle.unacknowledgedSuccess()?.reservation;
129
+ return reservation ? [{ handle, reservation }] : [];
130
+ }
131
+ catch {
132
+ return [];
133
+ }
134
+ });
135
+ /**
136
+ * The reservations this form's last run left unshown, or the last step
137
+ * `dismiss` closed while it was followed (`run`, whose later showing the
138
+ * user closed, never counts as seen), and the account that sent them.
139
+ * Their success, whoever acknowledged it, closes a later run under the
140
+ * same hold (its intent's, though the calls were refreshed) from the same
141
+ * account. Still pending, the hold refuses that run, which follows it again.
142
+ * One entry per account and reservation: keeping or showing one never
143
+ * touches another's.
144
+ */
145
+ const own = useRef(new Map());
146
+ /**
147
+ * Every change to a form's write, under the account that sent it, goes
148
+ * through {@link mergeOwn}: forward only. A showing (`shown`) changes
149
+ * the reservation's entry whichever account it is under.
150
+ */
151
+ const changeOwn = (account, reservations, change) => {
152
+ for (const reservation of reservations) {
153
+ if (!reservation)
154
+ continue;
155
+ const keys = [...own.current]
156
+ .filter(([, entry]) => change.kind === "shown" && entry.reservation === reservation)
157
+ .map(([key]) => key);
158
+ const mine = `${account.toLowerCase()}:${reservation}`;
159
+ for (const key of keys.length ? keys : [mine]) {
160
+ const entry = own.current.get(key);
161
+ own.current.set(key, mergeOwn(entry, entry?.account ?? account, reservation, change));
162
+ }
163
+ }
164
+ };
165
+ const ownSuccess = (action) => {
166
+ if (!deps?.account ||
167
+ (action.account &&
168
+ action.account.toLowerCase() !== deps.account.toLowerCase()))
169
+ return undefined;
170
+ const kept = [...own.current.values()]
171
+ .filter((entry) => entry.state === "owned" &&
172
+ entry.account.toLowerCase() === deps.account.toLowerCase())
173
+ .map((entry) => entry.reservation);
174
+ if (!kept.length)
175
+ return undefined;
176
+ // Only the action's last step's hold, as `follow` maps it: a success
177
+ // of an earlier step never completes the action. Its Safe mode may not
178
+ // be known yet; a reservation is only ever in its own mode's hold.
179
+ const account = deps.account;
180
+ const lasts = [false, true].map((safe) => {
181
+ const steps = heldSteps(account, safe, action);
182
+ return steps[steps.length - 1];
183
+ });
184
+ for (const { call, step } of lasts) {
185
+ try {
186
+ // A reservation is read only through its own hold: another hold
187
+ // (another intent, or another exact call) never answers for it.
188
+ const handle = openStepHold(deps.hold, deps.account, false, call);
189
+ for (const reservation of kept) {
190
+ const settled = handle.settlementOf(reservation);
191
+ if (settled?.result === "success")
192
+ return {
193
+ settled: settled,
194
+ handle,
195
+ account,
196
+ call: call,
197
+ step,
198
+ reservation,
199
+ };
200
+ }
201
+ }
202
+ catch {
203
+ /* A call the hold can't open holds nothing of this form's. */
204
+ }
205
+ }
206
+ return undefined;
207
+ };
75
208
  const replaced = () => {
76
209
  latest.current += 1;
77
210
  life.current.abort();
@@ -90,9 +223,12 @@ export function useReviewedRun(deps) {
90
223
  };
91
224
  }, []);
92
225
  /** How a followed step ended, as the run that followed it shows it. */
93
- const showSettled = (run, step, total, settled, receipt, acknowledge) => {
226
+ const showSettled = (run, step, total, settled, receipt, acknowledge, account, reservation) => {
94
227
  if (latest.current !== run)
95
228
  return;
229
+ // Shown: its own entry is released, unless `dismiss` closed it while
230
+ // this run followed it (its showing the user closed never counts).
231
+ changeOwn(account, [reservation], { kind: "shown", run });
96
232
  const finished = step === total - 1;
97
233
  setState((now) => settled.result !== "success"
98
234
  ? {
@@ -170,8 +306,18 @@ export function useReviewedRun(deps) {
170
306
  shown.phase === "settled"
171
307
  ? null
172
308
  : shown;
173
- const { cancelled: _cancelled, stopped: _stopped, acknowledge: _acknowledge, ...shownState } = state;
174
- const status = reviewedRunStatus({ ...state, held });
309
+ const { cancelled: _cancelled, stopped: _stopped, acknowledge: _acknowledge, away, ...shownState } = state;
310
+ const awayShown = away && !held
311
+ ? {
312
+ phase: "succeeded",
313
+ state: null,
314
+ line: HELD_AWAY_SUCCESS_LINE,
315
+ exit: false,
316
+ settled: { result: "success", hash: away.hash },
317
+ adopting: false,
318
+ }
319
+ : null;
320
+ const status = reviewedRunStatus({ ...state, held: held ?? awayShown });
175
321
  const reset = () => {
176
322
  replaced();
177
323
  setState(IDLE);
@@ -191,16 +337,43 @@ export function useReviewedRun(deps) {
191
337
  ...shownState,
192
338
  ...status,
193
339
  busy: status.busy && !state.following,
194
- held,
340
+ held: held ?? awayShown,
195
341
  ...actionsOf(shown, held ? view : undefined),
342
+ ...(awayShown && {
343
+ sendAgainAction: {
344
+ label: HELD_SEND_AGAIN_LABEL,
345
+ run: async () => {
346
+ await away.handle.acknowledge({
347
+ result: "success",
348
+ hash: away.hash,
349
+ });
350
+ setState((now) => (now.away === away ? { ...now, away: null } : now));
351
+ },
352
+ },
353
+ }),
196
354
  reset,
197
355
  dismiss: () => {
198
356
  const waiting = awaiting.current;
199
357
  if (open.current && !waiting)
200
358
  return false;
359
+ /**
360
+ * The followed last step this closes, under the account that sent it,
361
+ * and the run that followed it: a retry of it never sends it again.
362
+ */
363
+ const last = waiting
364
+ ? waiting.last && { ...waiting.owner, run: waiting.run }
365
+ : watched?.step === watched?.total - 1 && watched;
366
+ const closing = () => {
367
+ if (last)
368
+ changeOwn(last.account, [last.reservation], {
369
+ kind: "dismiss",
370
+ run: last.run,
371
+ });
372
+ };
201
373
  if (waiting?.last) {
202
374
  awaiting.current = null;
203
375
  waiting.end();
376
+ closing();
204
377
  return true;
205
378
  }
206
379
  detach(waiting
@@ -209,9 +382,13 @@ export function useReviewedRun(deps) {
209
382
  handle: watched.handle,
210
383
  client: watched.client,
211
384
  call: watched.call,
385
+ account: watched.account,
212
386
  reservation: watched.reservation,
213
387
  });
388
+ // Closing the view keeps what this form sent: a reset forgets no
389
+ // entry, so a later dismiss of the idle view never forgets one.
214
390
  reset();
391
+ closing();
215
392
  return true;
216
393
  },
217
394
  preload,
@@ -231,6 +408,102 @@ export function useReviewedRun(deps) {
231
408
  setWatched(null);
232
409
  },
233
410
  run: async ({ onCancelled, onStopped, ...action }) => {
411
+ /**
412
+ * The account this run sends from, read once: every continuation of
413
+ * it uses this one, whatever account the app's deps hold by then.
414
+ */
415
+ const runAccount = deps?.account;
416
+ /**
417
+ * The wallet's Safe mode, only while it still speaks for the run's
418
+ * account: another account's mode never names this run's holds.
419
+ */
420
+ const runSafe = () => deps && deps.account?.toLowerCase() === runAccount?.toLowerCase()
421
+ ? deps.adapter.isSafe()
422
+ : undefined;
423
+ /** This form's unshown write of these calls succeeded: the form closes on it. */
424
+ const close = async (found) => {
425
+ const { settled, handle } = found;
426
+ // Every exit holds the run open, and this form's ownership stays
427
+ // until its success is shown and acknowledged: until then a dismiss
428
+ // is refused and a later run closes on the same success again.
429
+ open.current += 1;
430
+ try {
431
+ // Completed as its run would have, under this run's locks: told to
432
+ // onConfirmed once (unless another completion did), and done only
433
+ // once delivered.
434
+ let failure;
435
+ const told = await load(deps.engine)
436
+ .then((engine) => engine.completeReviewedStep({
437
+ step: found.step,
438
+ held: { call: found.call, reservation: found.reservation },
439
+ }, {
440
+ account: found.account,
441
+ scope: action.scope,
442
+ onConfirmed: action.onConfirmed,
443
+ locks: action.locks,
444
+ attemptScoped: action.attemptScoped,
445
+ }, deps, null, alive, false))
446
+ .catch((error) => {
447
+ failure = error;
448
+ return null;
449
+ });
450
+ if (!told || told.undelivered) {
451
+ failure ??= new LockLostError(action.locks?.[0]?.name ?? "site");
452
+ const error = errorLine(failure);
453
+ update({ ...IDLE, error, failure });
454
+ return { status: "failed", error, failure };
455
+ }
456
+ const step = action.calls.length - 1;
457
+ update({
458
+ ...IDLE,
459
+ // Shown now: acknowledged once rendered, so it blocks no new send.
460
+ // Rendered: the form's own is released, and a later run waits
461
+ // for this acknowledgement before it sends.
462
+ acknowledge: async () => {
463
+ if (alive.aborted)
464
+ return;
465
+ changeOwn(found.account, [found.reservation], {
466
+ kind: "shown",
467
+ });
468
+ await handle.acknowledge(settled).catch(() => undefined);
469
+ },
470
+ phase: "done",
471
+ step,
472
+ total: action.calls.length,
473
+ hash: settled.hash,
474
+ settled: { step, finished: true },
475
+ });
476
+ return {
477
+ status: "done",
478
+ hashes: told.hashes,
479
+ receipts: [],
480
+ ...(told.signatures ? { signatures: told.signatures } : {}),
481
+ };
482
+ }
483
+ finally {
484
+ open.current -= 1;
485
+ }
486
+ };
487
+ // This form's own write is still followed: a retry of it is refused
488
+ // with the hold's line, and the run following it goes on.
489
+ const now = watchedNow.current;
490
+ // Identity, never bytes: the followed reservation is one of these
491
+ // calls' holds'. A scoped sequence's journal answers for its own.
492
+ const followed = now?.reservation &&
493
+ runAccount &&
494
+ (action.scope === undefined || action.calls.length === 1)
495
+ ? now
496
+ : null;
497
+ const ownState = followed?.handle.peek();
498
+ if (followed &&
499
+ ownState?.status !== "clear" &&
500
+ ownState?.reservation === followed.reservation &&
501
+ holdsOf(runAccount, action.calls).some(({ reservation }) => reservation === followed.reservation)) {
502
+ const failure = new WriteHeldError(ownState);
503
+ const error = errorLine(failure);
504
+ setState((now) => ({ ...now, error, failure }));
505
+ return { status: "failed", error, failure };
506
+ }
234
507
  replaced();
235
508
  const id = latest.current;
236
509
  const { signal: alive } = life.current;
@@ -244,6 +517,21 @@ export function useReviewedRun(deps) {
244
517
  setState((now) => ({ ...now, ...next }));
245
518
  };
246
519
  setState({ ...IDLE, phase: "review", total: action.calls.length });
520
+ const succeeded = ownSuccess(action);
521
+ if (succeeded) {
522
+ setWatched(null);
523
+ // close holds the run open itself, from here until it ends.
524
+ open.current -= 1;
525
+ return close(succeeded);
526
+ }
527
+ /**
528
+ * Reservations the action's holds had before this run: another run's
529
+ * (another form's, sharing a hold). This run never follows one.
530
+ */
531
+ const holds = () => holdsOf(runAccount, runAccount && action.batch && action.calls.length > 1
532
+ ? [...action.calls, safeBatchCall(runAccount, action.calls)]
533
+ : action.calls);
534
+ const before = new Set(holds().map(({ reservation }) => reservation));
247
535
  let loaded;
248
536
  /** A step of the action was sent before this run, or by it: never a cancel. */
249
537
  let sentAny = false;
@@ -272,9 +560,11 @@ export function useReviewedRun(deps) {
272
560
  let completed = Promise.resolve(null);
273
561
  let shown = false;
274
562
  const action$ = {
275
- account: deps.account,
563
+ account: runAccount,
276
564
  scope: action.scope,
277
565
  onConfirmed: action.onConfirmed,
566
+ locks: action.locks,
567
+ attemptScoped: action.attemptScoped,
278
568
  };
279
569
  const finish = (seen, receipt = null) => {
280
570
  if (shown || alive.aborted)
@@ -288,11 +578,30 @@ export function useReviewedRun(deps) {
288
578
  return null;
289
579
  }));
290
580
  void completed.then((ended) => {
581
+ // Its notice undelivered: the form's own, whichever run shows
582
+ // now (a retry that replaced this one included), so a later
583
+ // run closes on it, telling it then.
584
+ if (ended?.undelivered && target.step === total - 1)
585
+ changeOwn(action$.account, [target.held?.reservation], {
586
+ kind: "keep",
587
+ });
291
588
  if (shown || alive.aborted)
292
589
  return;
293
- // Nothing advanced (its receipt unread, or its hold not settled
294
- // for this send yet): it is tried again.
295
- if (unread || (!ended && seen)) {
590
+ // Nothing advanced (its receipt unread, its hold not settled
591
+ // for this send yet, or its onConfirmed not delivered): it is
592
+ // tried again, still followed and acknowledged by nothing.
593
+ // ponytail: retried at the receipt's pace while mounted.
594
+ if (unread || (!ended && seen) || ended?.undelivered) {
595
+ if (ended?.undelivered) {
596
+ completed = Promise.resolve(null);
597
+ // While the site's work under its locks runs, its notice may
598
+ // wait for it (kept for a lock this run lost, or another
599
+ // completion's claim queued behind one it holds): the wait
600
+ // ends now, retryable, and is never what holds that work up.
601
+ // Once that work ended, nothing of the caller's is in its way.
602
+ if (action.locks?.some(lockWorkRunning))
603
+ end?.("queued");
604
+ }
296
605
  if (mounted.current)
297
606
  setTimeout(() => finish(seen, receipt), RECEIPT_RETRY_MS);
298
607
  return;
@@ -302,8 +611,8 @@ export function useReviewedRun(deps) {
302
611
  shown = true;
303
612
  // Shown with the receipt completion checked, a failure's too.
304
613
  showSettled(id, target.step, total, ended.settled, ended.receipt, ended.settled.result === "success" && target.step === total - 1
305
- ? acknowledging(openStepHold(deps.hold, deps.account, false, target.held.call), ended.settled)
306
- : null);
614
+ ? acknowledging(openStepHold(deps.hold, action$.account, false, target.held.call), ended.settled)
615
+ : null, action$.account, target.held?.reservation);
307
616
  setWatched(null);
308
617
  end?.(ended);
309
618
  });
@@ -315,9 +624,10 @@ export function useReviewedRun(deps) {
315
624
  * found one, or null when the wallet could not say (its Safe mode unknown).
316
625
  */
317
626
  const follow = async (finish, released) => {
318
- if (latest.current !== id || !deps?.account)
627
+ if (latest.current !== id || !deps || !runAccount)
319
628
  return false;
320
- const { account, adapter, hold, storage } = deps;
629
+ const { adapter, hold, storage } = deps;
630
+ const account = runAccount;
321
631
  // Its Safe mode is read only once the wallet says it is known.
322
632
  try {
323
633
  await walletReady(adapter);
@@ -328,36 +638,55 @@ export function useReviewedRun(deps) {
328
638
  try {
329
639
  if (latest.current !== id)
330
640
  return false;
331
- const safe = adapter.isSafe();
641
+ const safe = runSafe();
642
+ if (safe === undefined)
643
+ return null;
332
644
  const saved = !action.scope
333
645
  ? null
334
646
  : findReviewedSequence(action.scope, account, { storage });
335
647
  const total = saved?.calls.length ?? action.calls.length;
336
- // A Safe app's batch is held as its one MultiSend proposal, its last step.
337
- const steps = saved?.sending
338
- ? [
339
- {
340
- call: saved.calls[saved.sending.step],
341
- step: saved.sending.step,
342
- },
343
- ]
344
- : !saved && action.batch && safe && action.calls.length > 1
345
- ? [
346
- {
347
- call: safeBatchCall(account, action.calls),
348
- step: total - 1,
349
- },
350
- ]
351
- : action.calls.map((call, step) => ({ call, step }));
352
- // A held step, or the last step's success nothing showed.
648
+ const steps = heldSteps(account, safe, action, saved);
649
+ // A held step, or the last step's success nothing showed, of this
650
+ // run (or of the saved sequence it resumes), never another run's.
651
+ let away = null;
353
652
  const found = steps
354
653
  .map(({ call, step }) => ({
355
654
  call,
356
655
  step,
357
656
  handle: openStepHold(hold, account, safe, call),
358
657
  }))
359
- .find(({ handle, step }) => handle.peek().status !== "clear" ||
360
- (step === total - 1 && handle.unacknowledgedSuccess()));
658
+ .find(({ handle, step, call }) => {
659
+ const held = handle.peek();
660
+ const success = step === total - 1 ? handle.unacknowledgedSuccess() : undefined;
661
+ if (held.status === "clear" && !success)
662
+ return false;
663
+ const reservation = held.status !== "clear"
664
+ ? held.reservation
665
+ : success?.reservation;
666
+ // A write held before this run, under an intent other forms
667
+ // share, is followed only when this runner followed it before,
668
+ // or it is this exact call (or this saved sequence's): another
669
+ // form's write is never adopted.
670
+ if (!reservation ||
671
+ !before.has(reservation) ||
672
+ mine.current.has(reservation) ||
673
+ reservation === saved?.sending?.reservation ||
674
+ !call.intent)
675
+ return true;
676
+ // A success another runner left settled while away, of this
677
+ // call or another form's: only shown, with "Send it again", so
678
+ // the refused form is never stuck. Never followed, so no
679
+ // dismissal makes it this form's to close a changed call on.
680
+ if (held.status === "clear") {
681
+ away = { handle, hash: success.hash };
682
+ return false;
683
+ }
684
+ const record = listReviewedWrites({ chainId: call.chainId, account }, { storage }).find(({ record }) => record.id === reservation)?.record;
685
+ return (!!record &&
686
+ record.call.to.toLowerCase() === call.to.toLowerCase() &&
687
+ record.call.data.toLowerCase() === call.data.toLowerCase() &&
688
+ record.call.value === String(call.value ?? 0n));
689
+ });
361
690
  // Its settlement completes it as a send's would: a proposal too.
362
691
  const reservation = found?.handle.peek().reservation ??
363
692
  found?.handle.unacknowledgedSuccess()?.reservation;
@@ -366,6 +695,7 @@ export function useReviewedRun(deps) {
366
695
  handle: found.handle,
367
696
  client: deps.clientFor(found.call.chainId),
368
697
  run: id,
698
+ account,
369
699
  call: found.call,
370
700
  reservation,
371
701
  step: found.step,
@@ -379,8 +709,9 @@ export function useReviewedRun(deps) {
379
709
  }
380
710
  : null);
381
711
  // Only its hold is followed now: the confirm can close.
382
- if (found)
383
- update({ following: true });
712
+ if (reservation)
713
+ mine.current.add(reservation);
714
+ update(found ? { following: true, away: null } : { away });
384
715
  return !!found;
385
716
  }
386
717
  catch {
@@ -390,13 +721,17 @@ export function useReviewedRun(deps) {
390
721
  };
391
722
  const heldStep = (target) => {
392
723
  const call = target.held?.call ?? action.calls[target.step];
393
- if (!deps?.account || !call)
724
+ if (!deps || !runAccount || !call)
394
725
  return null;
395
726
  try {
727
+ const safe = runSafe();
728
+ if (safe === undefined)
729
+ return null;
396
730
  return {
397
- handle: openStepHold(deps.hold, deps.account, deps.adapter.isSafe(), call),
731
+ handle: openStepHold(deps.hold, runAccount, safe, call),
398
732
  client: deps.clientFor(call.chainId),
399
733
  call,
734
+ account: runAccount,
400
735
  reservation: target.held?.reservation,
401
736
  };
402
737
  }
@@ -405,10 +740,10 @@ export function useReviewedRun(deps) {
405
740
  }
406
741
  };
407
742
  try {
408
- if (!deps?.account)
743
+ if (!deps || !runAccount)
409
744
  throw new ReviewLineError("Connect a wallet first.");
410
745
  if (action.account &&
411
- action.account.toLowerCase() !== deps.account.toLowerCase())
746
+ action.account.toLowerCase() !== runAccount.toLowerCase())
412
747
  throw new ReviewLineError(REVIEWED_ACCOUNT_CHANGED_LINE);
413
748
  if (action.calls.length > 1 && action.scope === undefined)
414
749
  throw new ReviewLineError(REVIEWED_RUN_SCOPE_LINE);
@@ -429,7 +764,14 @@ export function useReviewedRun(deps) {
429
764
  const { resumeReviewed, sendReviewed } = loaded;
430
765
  // A success already shown is acknowledged before this run checks it.
431
766
  await acknowledged.current;
432
- const { account, guard } = deps;
767
+ const settledOwn = () => {
768
+ const settled = ownSuccess(action);
769
+ if (settled)
770
+ throw new OwnSucceeded(settled);
771
+ };
772
+ settledOwn();
773
+ const { guard } = deps;
774
+ const account = runAccount;
433
775
  const tracked = {
434
776
  ...deps,
435
777
  onProgress: (progress) => {
@@ -486,9 +828,20 @@ export function useReviewedRun(deps) {
486
828
  tell(action.follow?.onConfirming, step);
487
829
  },
488
830
  };
831
+ const assertWriteState = () => {
832
+ settledOwn();
833
+ action.assertWriteState?.();
834
+ };
835
+ const gated = {
836
+ ...action,
837
+ guard,
838
+ follow: inPlace,
839
+ onSubmitted,
840
+ assertWriteState,
841
+ };
489
842
  const result = saved
490
- ? await resumeReviewed(saved, { ...action, guard, follow: inPlace, onSubmitted }, tracked)
491
- : await sendReviewed({ ...action, account, guard, follow: inPlace, onSubmitted }, tracked);
843
+ ? await resumeReviewed(saved, gated, tracked)
844
+ : await sendReviewed({ ...gated, account }, tracked);
492
845
  // A wallet that reviews on its own page reports no progress: the
493
846
  // result is the run's last word.
494
847
  const calls = saved?.calls ?? action.calls;
@@ -513,6 +866,8 @@ export function useReviewedRun(deps) {
513
866
  return result;
514
867
  }
515
868
  catch (error) {
869
+ if (error instanceof OwnSucceeded)
870
+ return close(error.found);
516
871
  if (error instanceof ReviewedReceiptUnverifiedError) {
517
872
  // Still confirming: followed until its hold settles it.
518
873
  update({
@@ -541,7 +896,12 @@ export function useReviewedRun(deps) {
541
896
  leave.current = abandon;
542
897
  cancelled?.addEventListener("abort", abandon, { once: true });
543
898
  awaiting.current = {
899
+ run: id,
544
900
  last: error.step === action.calls.length - 1,
901
+ owner: {
902
+ account: runAccount,
903
+ reservation: error.held?.reservation,
904
+ },
545
905
  detached: heldStep(error),
546
906
  end: abandon,
547
907
  };
@@ -549,7 +909,6 @@ export function useReviewedRun(deps) {
549
909
  const finish = completing(error, action.calls.length, end);
550
910
  // Followed for a later settlement; one already there is read once it attaches.
551
911
  void follow(finish, () => end(null)).then(() => finish());
552
- const before = error.held?.hashes ?? [];
553
912
  return {
554
913
  status: "receipt",
555
914
  step: error.step,
@@ -560,6 +919,11 @@ export function useReviewedRun(deps) {
560
919
  ended: () => outcome.then(async (ran) => {
561
920
  if (!ran)
562
921
  return null;
922
+ if (ran === "queued") {
923
+ const failure = new ConfirmationQueuedError(error.step);
924
+ const line = errorLine(failure);
925
+ return { status: "failed", error: line, failure };
926
+ }
563
927
  const { settled, receipt } = ran;
564
928
  if (settled.result !== "success") {
565
929
  const line = settledLine(settled);
@@ -569,7 +933,8 @@ export function useReviewedRun(deps) {
569
933
  failure: new ReviewLineError(line),
570
934
  };
571
935
  }
572
- const signatures = error.held?.signatures;
936
+ // The run's own, as its hold saved them with the step.
937
+ const { signatures } = ran;
573
938
  if (error.step < action.calls.length - 1) {
574
939
  const started = await runLatest.current({
575
940
  ...action,
@@ -587,7 +952,7 @@ export function useReviewedRun(deps) {
587
952
  }
588
953
  return {
589
954
  status: "done",
590
- hashes: [...before, settled.hash],
955
+ hashes: ran.hashes,
591
956
  receipts: receipt ? [receipt] : [],
592
957
  ...(signatures ? { signatures } : {}),
593
958
  };
@@ -596,9 +961,9 @@ export function useReviewedRun(deps) {
596
961
  }
597
962
  // A resume can complete a step the wallet sent before the reload.
598
963
  try {
599
- const after = action.scope === undefined || !deps?.account
964
+ const after = action.scope === undefined || !runAccount
600
965
  ? null
601
- : findReviewedSequence(action.scope, deps.account, {
966
+ : findReviewedSequence(action.scope, runAccount, {
602
967
  storage: deps.storage,
603
968
  });
604
969
  if (after?.completed.length &&
@@ -610,6 +975,19 @@ export function useReviewedRun(deps) {
610
975
  catch {
611
976
  /* An unreadable sequence adds nothing to what this run saw. */
612
977
  }
978
+ // Refused by a write already held (another form's, sharing the
979
+ // hold): this run follows it only as its own exact call.
980
+ const refused = error instanceof WriteHeldError
981
+ ? error.state.reservation
982
+ : error instanceof WriteHoldUnacknowledgedSuccessError
983
+ ? error.success.reservation
984
+ : undefined;
985
+ if (refused)
986
+ before.add(refused);
987
+ // ponytail: one call only; a sequence's journal keeps its steps.
988
+ const left = holds().filter(({ reservation }) => !before.has(reservation));
989
+ if (action.calls.length === 1 && left.length && runAccount)
990
+ changeOwn(runAccount, left.map(({ reservation }) => reservation), { kind: "keep" });
613
991
  const cancelled = error instanceof TransactionReviewCancelledError ||
614
992
  isDefiniteWalletRejection(error);
615
993
  const line = cancelled ? null : errorLine(error);