@bananapus/nana-sdk-core 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) 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/heldWrite.d.ts +6 -1
  6. package/dist/cjs/review/heldWrite.d.ts.map +1 -1
  7. package/dist/cjs/review/heldWrite.js +13 -6
  8. package/dist/cjs/review/heldWrite.js.map +1 -1
  9. package/dist/cjs/review/locks.d.ts +141 -2
  10. package/dist/cjs/review/locks.d.ts.map +1 -1
  11. package/dist/cjs/review/locks.js +406 -58
  12. package/dist/cjs/review/locks.js.map +1 -1
  13. package/dist/cjs/review/messages.d.ts +16 -0
  14. package/dist/cjs/review/messages.d.ts.map +1 -1
  15. package/dist/cjs/review/messages.js +17 -1
  16. package/dist/cjs/review/messages.js.map +1 -1
  17. package/dist/cjs/review/react/ownership.d.ts +30 -0
  18. package/dist/cjs/review/react/ownership.d.ts.map +1 -0
  19. package/dist/cjs/review/react/ownership.js +27 -0
  20. package/dist/cjs/review/react/ownership.js.map +1 -0
  21. package/dist/cjs/review/react/useReviewedRun.d.ts +17 -3
  22. package/dist/cjs/review/react/useReviewedRun.d.ts.map +1 -1
  23. package/dist/cjs/review/react/useReviewedRun.js +528 -68
  24. package/dist/cjs/review/react/useReviewedRun.js.map +1 -1
  25. package/dist/cjs/review/react/useSavedAttempt.d.ts.map +1 -1
  26. package/dist/cjs/review/react/useSavedAttempt.js +12 -0
  27. package/dist/cjs/review/react/useSavedAttempt.js.map +1 -1
  28. package/dist/cjs/review/relayrSend.d.ts +20 -0
  29. package/dist/cjs/review/relayrSend.d.ts.map +1 -1
  30. package/dist/cjs/review/relayrSend.js +150 -105
  31. package/dist/cjs/review/relayrSend.js.map +1 -1
  32. package/dist/cjs/review/runStatus.d.ts +6 -2
  33. package/dist/cjs/review/runStatus.d.ts.map +1 -1
  34. package/dist/cjs/review/runStatus.js +3 -1
  35. package/dist/cjs/review/runStatus.js.map +1 -1
  36. package/dist/cjs/review/safeRelayr.d.ts +12 -2
  37. package/dist/cjs/review/safeRelayr.d.ts.map +1 -1
  38. package/dist/cjs/review/safeRelayr.js +226 -156
  39. package/dist/cjs/review/safeRelayr.js.map +1 -1
  40. package/dist/cjs/review/savedAttempt.d.ts.map +1 -1
  41. package/dist/cjs/review/savedAttempt.js +7 -2
  42. package/dist/cjs/review/savedAttempt.js.map +1 -1
  43. package/dist/cjs/review/send.d.ts +46 -5
  44. package/dist/cjs/review/send.d.ts.map +1 -1
  45. package/dist/cjs/review/send.js +251 -58
  46. package/dist/cjs/review/send.js.map +1 -1
  47. package/dist/cjs/review/sequence.d.ts +25 -0
  48. package/dist/cjs/review/sequence.d.ts.map +1 -1
  49. package/dist/cjs/review/sequence.js +45 -7
  50. package/dist/cjs/review/sequence.js.map +1 -1
  51. package/dist/cjs/review/sign.d.ts +6 -1
  52. package/dist/cjs/review/sign.d.ts.map +1 -1
  53. package/dist/cjs/review/sign.js +10 -4
  54. package/dist/cjs/review/sign.js.map +1 -1
  55. package/dist/cjs/review/tabState.d.ts +4 -0
  56. package/dist/cjs/review/tabState.d.ts.map +1 -1
  57. package/dist/cjs/review/tabState.js +4 -0
  58. package/dist/cjs/review/tabState.js.map +1 -1
  59. package/dist/cjs/review/wallet.d.ts +16 -4
  60. package/dist/cjs/review/wallet.d.ts.map +1 -1
  61. package/dist/cjs/review/wallet.js +62 -17
  62. package/dist/cjs/review/wallet.js.map +1 -1
  63. package/dist/cjs/review/writeHold.d.ts +92 -14
  64. package/dist/cjs/review/writeHold.d.ts.map +1 -1
  65. package/dist/cjs/review/writeHold.js +102 -26
  66. package/dist/cjs/review/writeHold.js.map +1 -1
  67. package/dist/cjs/review/writeRecovery.d.ts +42 -7
  68. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  69. package/dist/cjs/review/writeRecovery.js +183 -21
  70. package/dist/cjs/review/writeRecovery.js.map +1 -1
  71. package/dist/cjs/testing.d.ts +13 -0
  72. package/dist/cjs/testing.d.ts.map +1 -1
  73. package/dist/cjs/testing.js +21 -0
  74. package/dist/cjs/testing.js.map +1 -1
  75. package/dist/esm/review/confirming.d.ts +8 -0
  76. package/dist/esm/review/confirming.d.ts.map +1 -0
  77. package/dist/esm/review/confirming.js +8 -0
  78. package/dist/esm/review/confirming.js.map +1 -0
  79. package/dist/esm/review/heldWrite.d.ts +6 -1
  80. package/dist/esm/review/heldWrite.d.ts.map +1 -1
  81. package/dist/esm/review/heldWrite.js +13 -6
  82. package/dist/esm/review/heldWrite.js.map +1 -1
  83. package/dist/esm/review/locks.d.ts +141 -2
  84. package/dist/esm/review/locks.d.ts.map +1 -1
  85. package/dist/esm/review/locks.js +396 -58
  86. package/dist/esm/review/locks.js.map +1 -1
  87. package/dist/esm/review/messages.d.ts +16 -0
  88. package/dist/esm/review/messages.d.ts.map +1 -1
  89. package/dist/esm/review/messages.js +16 -0
  90. package/dist/esm/review/messages.js.map +1 -1
  91. package/dist/esm/review/react/ownership.d.ts +30 -0
  92. package/dist/esm/review/react/ownership.d.ts.map +1 -0
  93. package/dist/esm/review/react/ownership.js +24 -0
  94. package/dist/esm/review/react/ownership.js.map +1 -0
  95. package/dist/esm/review/react/useReviewedRun.d.ts +17 -3
  96. package/dist/esm/review/react/useReviewedRun.d.ts.map +1 -1
  97. package/dist/esm/review/react/useReviewedRun.js +529 -69
  98. package/dist/esm/review/react/useReviewedRun.js.map +1 -1
  99. package/dist/esm/review/react/useSavedAttempt.d.ts.map +1 -1
  100. package/dist/esm/review/react/useSavedAttempt.js +12 -0
  101. package/dist/esm/review/react/useSavedAttempt.js.map +1 -1
  102. package/dist/esm/review/relayrSend.d.ts +20 -0
  103. package/dist/esm/review/relayrSend.d.ts.map +1 -1
  104. package/dist/esm/review/relayrSend.js +154 -109
  105. package/dist/esm/review/relayrSend.js.map +1 -1
  106. package/dist/esm/review/runStatus.d.ts +6 -2
  107. package/dist/esm/review/runStatus.d.ts.map +1 -1
  108. package/dist/esm/review/runStatus.js +3 -1
  109. package/dist/esm/review/runStatus.js.map +1 -1
  110. package/dist/esm/review/safeRelayr.d.ts +12 -2
  111. package/dist/esm/review/safeRelayr.d.ts.map +1 -1
  112. package/dist/esm/review/safeRelayr.js +226 -156
  113. package/dist/esm/review/safeRelayr.js.map +1 -1
  114. package/dist/esm/review/savedAttempt.d.ts.map +1 -1
  115. package/dist/esm/review/savedAttempt.js +7 -2
  116. package/dist/esm/review/savedAttempt.js.map +1 -1
  117. package/dist/esm/review/send.d.ts +46 -5
  118. package/dist/esm/review/send.d.ts.map +1 -1
  119. package/dist/esm/review/send.js +252 -59
  120. package/dist/esm/review/send.js.map +1 -1
  121. package/dist/esm/review/sequence.d.ts +25 -0
  122. package/dist/esm/review/sequence.d.ts.map +1 -1
  123. package/dist/esm/review/sequence.js +43 -7
  124. package/dist/esm/review/sequence.js.map +1 -1
  125. package/dist/esm/review/sign.d.ts +6 -1
  126. package/dist/esm/review/sign.d.ts.map +1 -1
  127. package/dist/esm/review/sign.js +10 -4
  128. package/dist/esm/review/sign.js.map +1 -1
  129. package/dist/esm/review/tabState.d.ts +4 -0
  130. package/dist/esm/review/tabState.d.ts.map +1 -1
  131. package/dist/esm/review/tabState.js +4 -0
  132. package/dist/esm/review/tabState.js.map +1 -1
  133. package/dist/esm/review/wallet.d.ts +16 -4
  134. package/dist/esm/review/wallet.d.ts.map +1 -1
  135. package/dist/esm/review/wallet.js +62 -17
  136. package/dist/esm/review/wallet.js.map +1 -1
  137. package/dist/esm/review/writeHold.d.ts +92 -14
  138. package/dist/esm/review/writeHold.d.ts.map +1 -1
  139. package/dist/esm/review/writeHold.js +102 -26
  140. package/dist/esm/review/writeHold.js.map +1 -1
  141. package/dist/esm/review/writeRecovery.d.ts +42 -7
  142. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  143. package/dist/esm/review/writeRecovery.js +180 -22
  144. package/dist/esm/review/writeRecovery.js.map +1 -1
  145. package/dist/esm/testing.d.ts +13 -0
  146. package/dist/esm/testing.d.ts.map +1 -1
  147. package/dist/esm/testing.js +20 -0
  148. package/dist/esm/testing.js.map +1 -1
  149. package/package.json +1 -1
@@ -1,10 +1,14 @@
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, 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, 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,
10
+ stopped: false,
11
+ following: false,
8
12
  lastSubmittedStep: null,
9
13
  phase: "idle",
10
14
  step: 0,
@@ -15,6 +19,7 @@ const IDLE = {
15
19
  safeTxHash: null,
16
20
  receipt: null,
17
21
  settled: null,
22
+ acknowledge: null,
18
23
  };
19
24
  /** The bytes a call sends, for comparing two actions. */
20
25
  const sent = ({ chainId, to, data, value, gas }) => [
@@ -24,6 +29,28 @@ const sent = ({ chainId, to, data, value, gas }) => [
24
29
  value ?? 0n,
25
30
  gas ?? null,
26
31
  ];
32
+ /** This form's earlier write of the same calls succeeded: the run closes on it, sending nothing. */
33
+ /**
34
+ * The holds `action`'s steps are held in, each with its step: a resumed
35
+ * sequence's sending step; a Safe app's batch as its one MultiSend
36
+ * proposal, its last step; or each call. The one home for that mapping.
37
+ */
38
+ function heldSteps(account, safe, action, saved) {
39
+ if (saved?.sending)
40
+ return [
41
+ { call: saved.calls[saved.sending.step], step: saved.sending.step },
42
+ ];
43
+ const total = saved?.calls.length ?? action.calls.length;
44
+ return !saved && action.batch && safe && action.calls.length > 1
45
+ ? [{ call: safeBatchCall(account, action.calls), step: total - 1 }]
46
+ : action.calls.map((call, step) => ({ call, step }));
47
+ }
48
+ class OwnSucceeded extends Error {
49
+ constructor(found) {
50
+ super("succeeded");
51
+ this.found = found;
52
+ }
53
+ }
27
54
  export const REVIEWED_RUN_SCOPE_LINE = "This action has more than one step and no scope to resume it by. Nothing was sent.";
28
55
  export const REVIEWED_RUN_BUSY_LINE = "This action is still running. Wait for it to finish, then try again.";
29
56
  /**
@@ -33,18 +60,46 @@ export const REVIEWED_RUN_BUSY_LINE = "This action is still running. Wait for it
33
60
  */
34
61
  export function useReviewedRun(deps) {
35
62
  const [state, setState] = useState(IDLE);
36
- /** A cancel to tell once the cancelled run has rendered. */
63
+ /** A cancel or stop to tell once rendered. */
37
64
  const [cancelling, setCancelling] = useState(null);
38
65
  useEffect(() => {
39
66
  if (cancelling)
40
- void tell(cancelling.onCancelled);
67
+ void tell(cancelling.told);
41
68
  }, [cancelling]);
69
+ /** Every shown success's acknowledgement: the next run waits for all. */
70
+ const acknowledged = useRef(Promise.resolve());
71
+ // A success is acknowledged only once its done rendered: a reset after
72
+ // that hides nothing, and one before it leaves the success warning. A
73
+ // later one never drops an earlier one still running from the wait.
74
+ useEffect(() => {
75
+ if (state.acknowledge)
76
+ acknowledged.current = Promise.all([
77
+ acknowledged.current,
78
+ state.acknowledge(),
79
+ ]).then(() => undefined);
80
+ }, [state.acknowledge]);
42
81
  /** The hold followed after a run, and its chain's client: its view is `held`. */
43
- const [watched, setWatched] = useState(null);
82
+ const [watched, setWatchedState] = useState(null);
83
+ /**
84
+ * `watched` as last set, for a handler that resets and runs from one
85
+ * render's handle (a "Try again"): never that render's stale copy.
86
+ */
87
+ const watchedNow = useRef(null);
88
+ const setWatched = (next) => {
89
+ watchedNow.current = next;
90
+ setWatchedState(next);
91
+ };
44
92
  /** The latest run: an earlier one's late answers change nothing. */
45
93
  const latest = useRef(0);
94
+ /** The sent step whose hold the latest run's `ended()` awaits. */
95
+ const awaiting = useRef(null);
46
96
  /** How many runs are open: a discard waits for none. */
47
97
  const open = useRef(0);
98
+ /**
99
+ * The latest run's life: aborted when it is replaced, reset, dismissed or
100
+ * unmounted, so its completion acknowledges nothing it can no longer show.
101
+ */
102
+ const life = useRef(new AbortController());
48
103
  const mounted = useRef(false);
49
104
  /** This render's `run`: a run's end that continues the action runs it. */
50
105
  const runLatest = useRef(async () => null);
@@ -54,8 +109,106 @@ export function useReviewedRun(deps) {
54
109
  * hold keeps the sent step.
55
110
  */
56
111
  const leave = useRef(() => { });
112
+ /** Every reservation this runner's runs followed: its own, whatever its calls now. */
113
+ const mine = useRef(new Set());
114
+ /**
115
+ * The reservations `calls`' holds hold now (a held write, or a success
116
+ * nothing showed), each with its hold.
117
+ */
118
+ const holdsOf = (account, calls) => calls.flatMap((call) => {
119
+ if (!deps || !account)
120
+ return [];
121
+ try {
122
+ // A hold's key is its call's, whatever the Safe mode.
123
+ const handle = openStepHold(deps.hold, account, false, call);
124
+ const held = handle.peek();
125
+ const reservation = held.status !== "clear"
126
+ ? held.reservation
127
+ : handle.unacknowledgedSuccess()?.reservation;
128
+ return reservation ? [{ handle, reservation }] : [];
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ });
134
+ /**
135
+ * The reservations this form's last run left unshown, or the last step
136
+ * `dismiss` closed while it was followed (`run`, whose later showing the
137
+ * user closed, never counts as seen), and the account that sent them.
138
+ * Their success, whoever acknowledged it, closes a later run under the
139
+ * same hold (its intent's, though the calls were refreshed) from the same
140
+ * account. Still pending, the hold refuses that run, which follows it again.
141
+ * One entry per account and reservation: keeping or showing one never
142
+ * touches another's.
143
+ */
144
+ const own = useRef(new Map());
145
+ /**
146
+ * Every change to a form's write, under the account that sent it, goes
147
+ * through {@link mergeOwn}: forward only. A showing (`shown`) changes
148
+ * the reservation's entry whichever account it is under.
149
+ */
150
+ const changeOwn = (account, reservations, change) => {
151
+ for (const reservation of reservations) {
152
+ if (!reservation)
153
+ continue;
154
+ const keys = [...own.current]
155
+ .filter(([, entry]) => change.kind === "shown" && entry.reservation === reservation)
156
+ .map(([key]) => key);
157
+ const mine = `${account.toLowerCase()}:${reservation}`;
158
+ for (const key of keys.length ? keys : [mine]) {
159
+ const entry = own.current.get(key);
160
+ own.current.set(key, mergeOwn(entry, entry?.account ?? account, reservation, change));
161
+ }
162
+ }
163
+ };
164
+ const ownSuccess = (action) => {
165
+ if (!deps?.account ||
166
+ (action.account &&
167
+ action.account.toLowerCase() !== deps.account.toLowerCase()))
168
+ return undefined;
169
+ const kept = [...own.current.values()]
170
+ .filter((entry) => entry.state === "owned" &&
171
+ entry.account.toLowerCase() === deps.account.toLowerCase())
172
+ .map((entry) => entry.reservation);
173
+ if (!kept.length)
174
+ return undefined;
175
+ // Only the action's last step's hold, as `follow` maps it: a success
176
+ // of an earlier step never completes the action. Its Safe mode may not
177
+ // be known yet; a reservation is only ever in its own mode's hold.
178
+ const account = deps.account;
179
+ const lasts = [false, true].map((safe) => {
180
+ const steps = heldSteps(account, safe, action);
181
+ return steps[steps.length - 1];
182
+ });
183
+ for (const { call, step } of lasts) {
184
+ try {
185
+ // A reservation is read only through its own hold: another hold
186
+ // (another intent, or another exact call) never answers for it.
187
+ const handle = openStepHold(deps.hold, deps.account, false, call);
188
+ for (const reservation of kept) {
189
+ const settled = handle.settlementOf(reservation);
190
+ if (settled?.result === "success")
191
+ return {
192
+ settled: settled,
193
+ handle,
194
+ account,
195
+ call: call,
196
+ step,
197
+ reservation,
198
+ };
199
+ }
200
+ }
201
+ catch {
202
+ /* A call the hold can't open holds nothing of this form's. */
203
+ }
204
+ }
205
+ return undefined;
206
+ };
57
207
  const replaced = () => {
58
208
  latest.current += 1;
209
+ life.current.abort();
210
+ life.current = new AbortController();
211
+ awaiting.current = null;
59
212
  const left = leave.current;
60
213
  leave.current = () => { };
61
214
  left();
@@ -69,9 +222,12 @@ export function useReviewedRun(deps) {
69
222
  };
70
223
  }, []);
71
224
  /** How a followed step ended, as the run that followed it shows it. */
72
- const showSettled = (run, step, total, settled, receipt) => {
225
+ const showSettled = (run, step, total, settled, receipt, acknowledge, account, reservation) => {
73
226
  if (latest.current !== run)
74
227
  return;
228
+ // Shown: its own entry is released, unless `dismiss` closed it while
229
+ // this run followed it (its showing the user closed never counts).
230
+ changeOwn(account, [reservation], { kind: "shown", run });
75
231
  const finished = step === total - 1;
76
232
  setState((now) => settled.result !== "success"
77
233
  ? {
@@ -83,12 +239,14 @@ export function useReviewedRun(deps) {
83
239
  : finished
84
240
  ? {
85
241
  ...now,
242
+ following: false,
86
243
  phase: "done",
87
244
  hash: settled.hash,
88
245
  receipt,
89
246
  error: null,
90
247
  failure: null,
91
248
  settled: { step, finished },
249
+ acknowledge,
92
250
  }
93
251
  : {
94
252
  ...IDLE,
@@ -109,6 +267,11 @@ export function useReviewedRun(deps) {
109
267
  // never stays proposed, and never shows done unverified.
110
268
  onSettled: (settled, receipt) => watched.finish(settled, receipt),
111
269
  onReleased: () => {
270
+ // A later reservation's release (another tab's) never ends a
271
+ // write that settled: its settlement completes it.
272
+ if (watched.reservation &&
273
+ watched.handle.settlementOf(watched.reservation))
274
+ return;
112
275
  watched.released?.();
113
276
  if (latest.current === watched.run)
114
277
  setState((now) => ({
@@ -142,16 +305,68 @@ export function useReviewedRun(deps) {
142
305
  shown.phase === "settled"
143
306
  ? null
144
307
  : shown;
145
- const { cancelled: _cancelled, ...shownState } = state;
308
+ const { cancelled: _cancelled, stopped: _stopped, acknowledge: _acknowledge, ...shownState } = state;
309
+ const status = reviewedRunStatus({ ...state, held });
310
+ const reset = () => {
311
+ replaced();
312
+ setState(IDLE);
313
+ setWatched(null);
314
+ };
315
+ const detach = (target) => {
316
+ // ponytail: unbounded; it ends once the hold is clear.
317
+ if (target)
318
+ void followHeldWrite(target.handle, target.client, {
319
+ signal: new AbortController().signal,
320
+ ...(target.reservation ? { reservation: target.reservation } : {}),
321
+ }).then((end) => end?.status === "clear" &&
322
+ end.settled?.result === "success" &&
323
+ tell(deps?.onDismissedSettled, target.call), () => undefined);
324
+ };
146
325
  const api = {
147
326
  ...shownState,
148
- ...reviewedRunStatus({ ...state, held }),
327
+ ...status,
328
+ busy: status.busy && !state.following,
149
329
  held,
150
330
  ...actionsOf(shown, held ? view : undefined),
151
- reset: () => {
152
- replaced();
153
- setState(IDLE);
154
- setWatched(null);
331
+ reset,
332
+ dismiss: () => {
333
+ const waiting = awaiting.current;
334
+ if (open.current && !waiting)
335
+ return false;
336
+ /**
337
+ * The followed last step this closes, under the account that sent it,
338
+ * and the run that followed it: a retry of it never sends it again.
339
+ */
340
+ const last = waiting
341
+ ? waiting.last && { ...waiting.owner, run: waiting.run }
342
+ : watched?.step === watched?.total - 1 && watched;
343
+ const closing = () => {
344
+ if (last)
345
+ changeOwn(last.account, [last.reservation], {
346
+ kind: "dismiss",
347
+ run: last.run,
348
+ });
349
+ };
350
+ if (waiting?.last) {
351
+ awaiting.current = null;
352
+ waiting.end();
353
+ closing();
354
+ return true;
355
+ }
356
+ detach(waiting
357
+ ? waiting.detached
358
+ : watched && {
359
+ handle: watched.handle,
360
+ client: watched.client,
361
+ call: watched.call,
362
+ account: watched.account,
363
+ reservation: watched.reservation,
364
+ });
365
+ // Closing the view keeps what this form sent: a reset forgets no
366
+ // entry, so a later dismiss of the idle view never forgets one.
367
+ reset();
368
+ closing();
369
+ return true;
155
370
  },
156
371
  preload,
157
372
  discard: async (scope, account = deps?.account) => {
@@ -169,9 +384,106 @@ export function useReviewedRun(deps) {
169
384
  setState(IDLE);
170
385
  setWatched(null);
171
386
  },
172
- run: async ({ onCancelled, ...action }) => {
387
+ run: async ({ onCancelled, onStopped, ...action }) => {
388
+ /**
389
+ * The account this run sends from, read once: every continuation of
390
+ * it uses this one, whatever account the app's deps hold by then.
391
+ */
392
+ const runAccount = deps?.account;
393
+ /**
394
+ * The wallet's Safe mode, only while it still speaks for the run's
395
+ * account: another account's mode never names this run's holds.
396
+ */
397
+ const runSafe = () => deps && deps.account?.toLowerCase() === runAccount?.toLowerCase()
398
+ ? deps.adapter.isSafe()
399
+ : undefined;
400
+ /** This form's unshown write of these calls succeeded: the form closes on it. */
401
+ const close = async (found) => {
402
+ const { settled, handle } = found;
403
+ // Every exit holds the run open, and this form's ownership stays
404
+ // until its success is shown and acknowledged: until then a dismiss
405
+ // is refused and a later run closes on the same success again.
406
+ open.current += 1;
407
+ try {
408
+ // Completed as its run would have, under this run's locks: told to
409
+ // onConfirmed once (unless another completion did), and done only
410
+ // once delivered.
411
+ let failure;
412
+ const told = await load(deps.engine)
413
+ .then((engine) => engine.completeReviewedStep({
414
+ step: found.step,
415
+ held: { call: found.call, reservation: found.reservation },
416
+ }, {
417
+ account: found.account,
418
+ scope: action.scope,
419
+ onConfirmed: action.onConfirmed,
420
+ locks: action.locks,
421
+ attemptScoped: action.attemptScoped,
422
+ }, deps, null, alive, false))
423
+ .catch((error) => {
424
+ failure = error;
425
+ return null;
426
+ });
427
+ if (!told || told.undelivered) {
428
+ failure ??= new LockLostError(action.locks?.[0]?.name ?? "site");
429
+ const error = errorLine(failure);
430
+ update({ ...IDLE, error, failure });
431
+ return { status: "failed", error, failure };
432
+ }
433
+ const step = action.calls.length - 1;
434
+ update({
435
+ ...IDLE,
436
+ // Shown now: acknowledged once rendered, so it blocks no new send.
437
+ // Rendered: the form's own is released, and a later run waits
438
+ // for this acknowledgement before it sends.
439
+ acknowledge: async () => {
440
+ if (alive.aborted)
441
+ return;
442
+ changeOwn(found.account, [found.reservation], {
443
+ kind: "shown",
444
+ });
445
+ await handle.acknowledge(settled).catch(() => undefined);
446
+ },
447
+ phase: "done",
448
+ step,
449
+ total: action.calls.length,
450
+ hash: settled.hash,
451
+ settled: { step, finished: true },
452
+ });
453
+ return {
454
+ status: "done",
455
+ hashes: told.hashes,
456
+ receipts: [],
457
+ ...(told.signatures ? { signatures: told.signatures } : {}),
458
+ };
459
+ }
460
+ finally {
461
+ open.current -= 1;
462
+ }
463
+ };
464
+ // This form's own write is still followed: a retry of it is refused
465
+ // with the hold's line, and the run following it goes on.
466
+ const now = watchedNow.current;
467
+ // Identity, never bytes: the followed reservation is one of these
468
+ // calls' holds'. A scoped sequence's journal answers for its own.
469
+ const followed = now?.reservation &&
470
+ runAccount &&
471
+ (action.scope === undefined || action.calls.length === 1)
472
+ ? now
473
+ : null;
474
+ const ownState = followed?.handle.peek();
475
+ if (followed &&
476
+ ownState?.status !== "clear" &&
477
+ ownState?.reservation === followed.reservation &&
478
+ holdsOf(runAccount, action.calls).some(({ reservation }) => reservation === followed.reservation)) {
479
+ const failure = new WriteHeldError(ownState);
480
+ const error = errorLine(failure);
481
+ setState((now) => ({ ...now, error, failure }));
482
+ return { status: "failed", error, failure };
483
+ }
173
484
  replaced();
174
485
  const id = latest.current;
486
+ const { signal: alive } = life.current;
175
487
  // A step still confirming is followed in place, in the same review,
176
488
  // until a reset or a later run ends it.
177
489
  const following = new AbortController();
@@ -182,6 +494,21 @@ export function useReviewedRun(deps) {
182
494
  setState((now) => ({ ...now, ...next }));
183
495
  };
184
496
  setState({ ...IDLE, phase: "review", total: action.calls.length });
497
+ const succeeded = ownSuccess(action);
498
+ if (succeeded) {
499
+ setWatched(null);
500
+ // close holds the run open itself, from here until it ends.
501
+ open.current -= 1;
502
+ return close(succeeded);
503
+ }
504
+ /**
505
+ * Reservations the action's holds had before this run: another run's
506
+ * (another form's, sharing a hold). This run never follows one.
507
+ */
508
+ const holds = () => holdsOf(runAccount, runAccount && action.batch && action.calls.length > 1
509
+ ? [...action.calls, safeBatchCall(runAccount, action.calls)]
510
+ : action.calls);
511
+ const before = new Set(holds().map(({ reservation }) => reservation));
185
512
  let loaded;
186
513
  /** A step of the action was sent before this run, or by it: never a cancel. */
187
514
  let sentAny = false;
@@ -193,36 +520,65 @@ export function useReviewedRun(deps) {
193
520
  update({ lastSubmittedStep: step });
194
521
  };
195
522
  setWatched(null);
523
+ /** Acknowledges a success this run showed, unless the run is gone. */
524
+ const acknowledging = (handle, settled) => async () => {
525
+ if (!alive.aborted)
526
+ await handle.acknowledge(settled).catch(() => undefined);
527
+ };
196
528
  /**
197
529
  * Completes a sent step once its hold settled it, as the run would
198
530
  * have (`completeReviewedStep`: the journal, `onConfirmed` once with
199
- * the receipt its hold verified, the acknowledgement), then shows how
200
- * it ended, once. Until it can (the receipt unread, or the hold not yet
531
+ * the receipt its hold verified), then shows how it ended, once. A
532
+ * last step's success is acknowledged once that rendered; an earlier
533
+ * step's at once, its sequence's journal keeping it from sending again. Until it can (the receipt unread, or the hold not yet
201
534
  * settled for this send), it is tried again, never shown.
202
535
  */
203
536
  const completing = (target, total, end) => {
204
537
  let completed = Promise.resolve(null);
205
538
  let shown = false;
206
539
  const action$ = {
207
- account: deps.account,
540
+ account: runAccount,
208
541
  scope: action.scope,
209
542
  onConfirmed: action.onConfirmed,
543
+ locks: action.locks,
544
+ attemptScoped: action.attemptScoped,
210
545
  };
211
546
  const finish = (seen, receipt = null) => {
547
+ if (shown || alive.aborted)
548
+ return;
212
549
  let unread = false;
213
550
  completed = completed.then((prior) => prior ??
214
551
  (loaded ? Promise.resolve(loaded) : load(deps.engine))
215
- .then((engine) => engine.completeReviewedStep(target, action$, deps, receipt))
552
+ .then((engine) => engine.completeReviewedStep(target, action$, deps, receipt, alive, target.step < total - 1))
216
553
  .catch(() => {
217
554
  unread = true;
218
555
  return null;
219
556
  }));
220
557
  void completed.then((ended) => {
221
- if (shown || latest.current !== id)
558
+ // Its notice undelivered: the form's own, whichever run shows
559
+ // now (a retry that replaced this one included), so a later
560
+ // run closes on it, telling it then.
561
+ if (ended?.undelivered && target.step === total - 1)
562
+ changeOwn(action$.account, [target.held?.reservation], {
563
+ kind: "keep",
564
+ });
565
+ if (shown || alive.aborted)
222
566
  return;
223
- // Nothing advanced (its receipt unread, or its hold not settled
224
- // for this send yet): it is tried again.
225
- if (unread || (!ended && seen)) {
567
+ // Nothing advanced (its receipt unread, its hold not settled
568
+ // for this send yet, or its onConfirmed not delivered): it is
569
+ // tried again, still followed and acknowledged by nothing.
570
+ // ponytail: retried at the receipt's pace while mounted.
571
+ if (unread || (!ended && seen) || ended?.undelivered) {
572
+ if (ended?.undelivered) {
573
+ completed = Promise.resolve(null);
574
+ // While the site's work under its locks runs, its notice may
575
+ // wait for it (kept for a lock this run lost, or another
576
+ // completion's claim queued behind one it holds): the wait
577
+ // ends now, retryable, and is never what holds that work up.
578
+ // Once that work ended, nothing of the caller's is in its way.
579
+ if (action.locks?.some(lockWorkRunning))
580
+ end?.("queued");
581
+ }
226
582
  if (mounted.current)
227
583
  setTimeout(() => finish(seen, receipt), RECEIPT_RETRY_MS);
228
584
  return;
@@ -231,7 +587,9 @@ export function useReviewedRun(deps) {
231
587
  return;
232
588
  shown = true;
233
589
  // Shown with the receipt completion checked, a failure's too.
234
- showSettled(id, target.step, total, ended.settled, ended.receipt);
590
+ showSettled(id, target.step, total, ended.settled, ended.receipt, ended.settled.result === "success" && target.step === total - 1
591
+ ? acknowledging(openStepHold(deps.hold, action$.account, false, target.held.call), ended.settled)
592
+ : null, action$.account, target.held?.reservation);
235
593
  setWatched(null);
236
594
  end?.(ended);
237
595
  });
@@ -243,9 +601,10 @@ export function useReviewedRun(deps) {
243
601
  * found one, or null when the wallet could not say (its Safe mode unknown).
244
602
  */
245
603
  const follow = async (finish, released) => {
246
- if (latest.current !== id || !deps?.account)
604
+ if (latest.current !== id || !deps || !runAccount)
247
605
  return false;
248
- const { account, adapter, hold, storage } = deps;
606
+ const { adapter, hold, storage } = deps;
607
+ const account = runAccount;
249
608
  // Its Safe mode is read only once the wallet says it is known.
250
609
  try {
251
610
  await walletReady(adapter);
@@ -256,36 +615,48 @@ export function useReviewedRun(deps) {
256
615
  try {
257
616
  if (latest.current !== id)
258
617
  return false;
259
- const safe = adapter.isSafe();
618
+ const safe = runSafe();
619
+ if (safe === undefined)
620
+ return null;
260
621
  const saved = !action.scope
261
622
  ? null
262
623
  : findReviewedSequence(action.scope, account, { storage });
263
624
  const total = saved?.calls.length ?? action.calls.length;
264
- // A Safe app's batch is held as its one MultiSend proposal, its last step.
265
- const steps = saved?.sending
266
- ? [
267
- {
268
- call: saved.calls[saved.sending.step],
269
- step: saved.sending.step,
270
- },
271
- ]
272
- : !saved && action.batch && safe && action.calls.length > 1
273
- ? [
274
- {
275
- call: safeBatchCall(account, action.calls),
276
- step: total - 1,
277
- },
278
- ]
279
- : action.calls.map((call, step) => ({ call, step }));
280
- // A held step, or the last step's success nothing showed.
625
+ const steps = heldSteps(account, safe, action, saved);
626
+ // A held step, or the last step's success nothing showed, of this
627
+ // run (or of the saved sequence it resumes), never another run's.
281
628
  const found = steps
282
629
  .map(({ call, step }) => ({
283
630
  call,
284
631
  step,
285
632
  handle: openStepHold(hold, account, safe, call),
286
633
  }))
287
- .find(({ handle, step }) => handle.peek().status !== "clear" ||
288
- (step === total - 1 && handle.unacknowledgedSuccess()));
634
+ .find(({ handle, step, call }) => {
635
+ const held = handle.peek();
636
+ const success = step === total - 1 ? handle.unacknowledgedSuccess() : undefined;
637
+ if (held.status === "clear" && !success)
638
+ return false;
639
+ const reservation = held.status !== "clear"
640
+ ? held.reservation
641
+ : success?.reservation;
642
+ // A write held before this run, under an intent other forms
643
+ // share, is followed only when this runner followed it before,
644
+ // or it is this exact call (or this saved sequence's): another
645
+ // form's write is never adopted.
646
+ if (!reservation ||
647
+ !before.has(reservation) ||
648
+ mine.current.has(reservation) ||
649
+ reservation === saved?.sending?.reservation ||
650
+ !call.intent)
651
+ return true;
652
+ if (held.status === "clear")
653
+ return false;
654
+ const record = listReviewedWrites({ chainId: call.chainId, account }, { storage }).find(({ record }) => record.id === reservation)?.record;
655
+ return (!!record &&
656
+ record.call.to.toLowerCase() === call.to.toLowerCase() &&
657
+ record.call.data.toLowerCase() === call.data.toLowerCase() &&
658
+ record.call.value === String(call.value ?? 0n));
659
+ });
289
660
  // Its settlement completes it as a send's would: a proposal too.
290
661
  const reservation = found?.handle.peek().reservation ??
291
662
  found?.handle.unacknowledgedSuccess()?.reservation;
@@ -294,6 +665,9 @@ export function useReviewedRun(deps) {
294
665
  handle: found.handle,
295
666
  client: deps.clientFor(found.call.chainId),
296
667
  run: id,
668
+ account,
669
+ call: found.call,
670
+ reservation,
297
671
  step: found.step,
298
672
  total,
299
673
  finish: finish ??
@@ -304,6 +678,11 @@ export function useReviewedRun(deps) {
304
678
  ...(released ? { released } : {}),
305
679
  }
306
680
  : null);
681
+ // Only its hold is followed now: the confirm can close.
682
+ if (reservation)
683
+ mine.current.add(reservation);
684
+ if (found)
685
+ update({ following: true });
307
686
  return !!found;
308
687
  }
309
688
  catch {
@@ -311,11 +690,31 @@ export function useReviewedRun(deps) {
311
690
  return false;
312
691
  }
313
692
  };
693
+ const heldStep = (target) => {
694
+ const call = target.held?.call ?? action.calls[target.step];
695
+ if (!deps || !runAccount || !call)
696
+ return null;
697
+ try {
698
+ const safe = runSafe();
699
+ if (safe === undefined)
700
+ return null;
701
+ return {
702
+ handle: openStepHold(deps.hold, runAccount, safe, call),
703
+ client: deps.clientFor(call.chainId),
704
+ call,
705
+ account: runAccount,
706
+ reservation: target.held?.reservation,
707
+ };
708
+ }
709
+ catch {
710
+ return null;
711
+ }
712
+ };
314
713
  try {
315
- if (!deps?.account)
714
+ if (!deps || !runAccount)
316
715
  throw new ReviewLineError("Connect a wallet first.");
317
716
  if (action.account &&
318
- action.account.toLowerCase() !== deps.account.toLowerCase())
717
+ action.account.toLowerCase() !== runAccount.toLowerCase())
319
718
  throw new ReviewLineError(REVIEWED_ACCOUNT_CHANGED_LINE);
320
719
  if (action.calls.length > 1 && action.scope === undefined)
321
720
  throw new ReviewLineError(REVIEWED_RUN_SCOPE_LINE);
@@ -334,7 +733,16 @@ export function useReviewedRun(deps) {
334
733
  }),
335
734
  ]).finally(() => signal?.removeEventListener("abort", withdraw));
336
735
  const { resumeReviewed, sendReviewed } = loaded;
337
- const { account, guard } = deps;
736
+ // A success already shown is acknowledged before this run checks it.
737
+ await acknowledged.current;
738
+ const settledOwn = () => {
739
+ const settled = ownSuccess(action);
740
+ if (settled)
741
+ throw new OwnSucceeded(settled);
742
+ };
743
+ settledOwn();
744
+ const { guard } = deps;
745
+ const account = runAccount;
338
746
  const tracked = {
339
747
  ...deps,
340
748
  onProgress: (progress) => {
@@ -391,30 +799,46 @@ export function useReviewedRun(deps) {
391
799
  tell(action.follow?.onConfirming, step);
392
800
  },
393
801
  };
802
+ const assertWriteState = () => {
803
+ settledOwn();
804
+ action.assertWriteState?.();
805
+ };
806
+ const gated = {
807
+ ...action,
808
+ guard,
809
+ follow: inPlace,
810
+ onSubmitted,
811
+ assertWriteState,
812
+ };
394
813
  const result = saved
395
- ? await resumeReviewed(saved, { ...action, guard, follow: inPlace, onSubmitted }, tracked)
396
- : await sendReviewed({ ...action, account, guard, follow: inPlace, onSubmitted }, tracked);
814
+ ? await resumeReviewed(saved, gated, tracked)
815
+ : await sendReviewed({ ...gated, account }, tracked);
397
816
  // A wallet that reviews on its own page reports no progress: the
398
817
  // result is the run's last word.
399
- update({ phase: result.status });
818
+ const calls = saved?.calls ?? action.calls;
819
+ const last = result.status === "done"
820
+ ? openStepHold(deps.hold, account, false, calls[calls.length - 1])
821
+ : null;
822
+ const success = last?.unacknowledgedSuccess();
823
+ update({
824
+ phase: result.status,
825
+ // Shown, its success blocks no later run.
826
+ acknowledge: last &&
827
+ success &&
828
+ result.status === "done" &&
829
+ result.hashes.some((h) => h.toLowerCase() === success.hash)
830
+ ? acknowledging(last, success)
831
+ : null,
832
+ });
400
833
  // Every step of a done run has its hash, a resumed one's too.
401
- submit(result.status === "done"
402
- ? (saved?.calls ?? action.calls).length - 1
403
- : result.step);
834
+ submit(result.status === "done" ? calls.length - 1 : result.step);
404
835
  if (result.status === "proposed")
405
836
  void follow();
406
- else if (mounted.current && latest.current === id) {
407
- // Shown: its success blocks no later run.
408
- const calls = saved?.calls ?? action.calls;
409
- const handle = openStepHold(deps.hold, account, false, calls[calls.length - 1]);
410
- const success = handle.unacknowledgedSuccess();
411
- if (success &&
412
- result.hashes.some((h) => h.toLowerCase() === success.hash))
413
- await handle.acknowledge(success).catch(() => undefined);
414
- }
415
837
  return result;
416
838
  }
417
839
  catch (error) {
840
+ if (error instanceof OwnSucceeded)
841
+ return close(error.found);
418
842
  if (error instanceof ReviewedReceiptUnverifiedError) {
419
843
  // Still confirming: followed until its hold settles it.
420
844
  update({
@@ -423,12 +847,15 @@ export function useReviewedRun(deps) {
423
847
  hash: error.hash,
424
848
  error: null,
425
849
  failure: null,
850
+ following: true,
426
851
  });
427
852
  let end;
428
853
  const cancelled = action.follow?.signal;
429
854
  const abandon = () => end(null);
430
855
  const outcome = new Promise((resolve) => (end = (ran) => {
431
856
  cancelled?.removeEventListener("abort", abandon);
857
+ if (awaiting.current?.end === abandon)
858
+ awaiting.current = null;
432
859
  resolve(ran);
433
860
  }));
434
861
  // Replaced, or the caller's follow ended (a cancel, unlike its
@@ -439,11 +866,20 @@ export function useReviewedRun(deps) {
439
866
  else {
440
867
  leave.current = abandon;
441
868
  cancelled?.addEventListener("abort", abandon, { once: true });
869
+ awaiting.current = {
870
+ run: id,
871
+ last: error.step === action.calls.length - 1,
872
+ owner: {
873
+ account: runAccount,
874
+ reservation: error.held?.reservation,
875
+ },
876
+ detached: heldStep(error),
877
+ end: abandon,
878
+ };
442
879
  }
443
880
  const finish = completing(error, action.calls.length, end);
444
881
  // Followed for a later settlement; one already there is read once it attaches.
445
882
  void follow(finish, () => end(null)).then(() => finish());
446
- const before = error.held?.hashes ?? [];
447
883
  return {
448
884
  status: "receipt",
449
885
  step: error.step,
@@ -454,6 +890,11 @@ export function useReviewedRun(deps) {
454
890
  ended: () => outcome.then(async (ran) => {
455
891
  if (!ran)
456
892
  return null;
893
+ if (ran === "queued") {
894
+ const failure = new ConfirmationQueuedError(error.step);
895
+ const line = errorLine(failure);
896
+ return { status: "failed", error: line, failure };
897
+ }
457
898
  const { settled, receipt } = ran;
458
899
  if (settled.result !== "success") {
459
900
  const line = settledLine(settled);
@@ -463,11 +904,13 @@ export function useReviewedRun(deps) {
463
904
  failure: new ReviewLineError(line),
464
905
  };
465
906
  }
466
- const signatures = error.held?.signatures;
907
+ // The run's own, as its hold saved them with the step.
908
+ const { signatures } = ran;
467
909
  if (error.step < action.calls.length - 1) {
468
910
  const started = await runLatest.current({
469
911
  ...action,
470
912
  ...(onCancelled ? { onCancelled } : {}),
913
+ ...(onStopped ? { onStopped } : {}),
471
914
  });
472
915
  const next = started?.status === "receipt"
473
916
  ? await started.ended()
@@ -480,7 +923,7 @@ export function useReviewedRun(deps) {
480
923
  }
481
924
  return {
482
925
  status: "done",
483
- hashes: [...before, settled.hash],
926
+ hashes: ran.hashes,
484
927
  receipts: receipt ? [receipt] : [],
485
928
  ...(signatures ? { signatures } : {}),
486
929
  };
@@ -489,9 +932,9 @@ export function useReviewedRun(deps) {
489
932
  }
490
933
  // A resume can complete a step the wallet sent before the reload.
491
934
  try {
492
- const after = action.scope === undefined || !deps?.account
935
+ const after = action.scope === undefined || !runAccount
493
936
  ? null
494
- : findReviewedSequence(action.scope, deps.account, {
937
+ : findReviewedSequence(action.scope, runAccount, {
495
938
  storage: deps.storage,
496
939
  });
497
940
  if (after?.completed.length &&
@@ -503,6 +946,19 @@ export function useReviewedRun(deps) {
503
946
  catch {
504
947
  /* An unreadable sequence adds nothing to what this run saw. */
505
948
  }
949
+ // Refused by a write already held (another form's, sharing the
950
+ // hold): this run follows it only as its own exact call.
951
+ const refused = error instanceof WriteHeldError
952
+ ? error.state.reservation
953
+ : error instanceof WriteHoldUnacknowledgedSuccessError
954
+ ? error.success.reservation
955
+ : undefined;
956
+ if (refused)
957
+ before.add(refused);
958
+ // ponytail: one call only; a sequence's journal keeps its steps.
959
+ const left = holds().filter(({ reservation }) => !before.has(reservation));
960
+ if (action.calls.length === 1 && left.length && runAccount)
961
+ changeOwn(runAccount, left.map(({ reservation }) => reservation), { kind: "keep" });
506
962
  const cancelled = error instanceof TransactionReviewCancelledError ||
507
963
  isDefiniteWalletRejection(error);
508
964
  const line = cancelled ? null : errorLine(error);
@@ -519,9 +975,13 @@ export function useReviewedRun(deps) {
519
975
  return;
520
976
  if (found === null)
521
977
  setTimeout(settle, RECEIPT_RETRY_MS);
522
- else if (cancelled && !sentAny && !found) {
523
- update({ cancelled: true });
524
- setCancelling(onCancelled ? { onCancelled } : null);
978
+ else if (cancelled && !found) {
979
+ const step = submitted ?? 0;
980
+ update(sentAny ? { stopped: true } : { cancelled: true });
981
+ const told = sentAny
982
+ ? onStopped && (() => onStopped(step))
983
+ : onCancelled;
984
+ setCancelling(told ? { told } : null);
525
985
  }
526
986
  });
527
987
  settle();