@bananapus/nana-sdk-core 2.34.0 → 2.35.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.
- package/dist/cjs/review/index.d.ts +1 -0
- package/dist/cjs/review/index.d.ts.map +1 -1
- package/dist/cjs/review/index.js +1 -0
- package/dist/cjs/review/index.js.map +1 -1
- package/dist/cjs/review/messages.d.ts +2 -0
- package/dist/cjs/review/messages.d.ts.map +1 -1
- package/dist/cjs/review/messages.js +3 -1
- package/dist/cjs/review/messages.js.map +1 -1
- package/dist/cjs/review/writeHold.d.ts +53 -59
- package/dist/cjs/review/writeHold.d.ts.map +1 -1
- package/dist/cjs/review/writeHold.js +440 -26
- package/dist/cjs/review/writeHold.js.map +1 -1
- package/dist/cjs/review/writeIntent.d.ts +41 -0
- package/dist/cjs/review/writeIntent.d.ts.map +1 -0
- package/dist/cjs/review/writeIntent.js +77 -0
- package/dist/cjs/review/writeIntent.js.map +1 -0
- package/dist/cjs/review/writeRecovery.d.ts +21 -0
- package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
- package/dist/cjs/review/writeRecovery.js +65 -0
- package/dist/cjs/review/writeRecovery.js.map +1 -1
- package/dist/esm/review/index.d.ts +1 -0
- package/dist/esm/review/index.d.ts.map +1 -1
- package/dist/esm/review/index.js +1 -0
- package/dist/esm/review/index.js.map +1 -1
- package/dist/esm/review/messages.d.ts +2 -0
- package/dist/esm/review/messages.d.ts.map +1 -1
- package/dist/esm/review/messages.js +2 -0
- package/dist/esm/review/messages.js.map +1 -1
- package/dist/esm/review/writeHold.d.ts +53 -59
- package/dist/esm/review/writeHold.d.ts.map +1 -1
- package/dist/esm/review/writeHold.js +439 -26
- package/dist/esm/review/writeHold.js.map +1 -1
- package/dist/esm/review/writeIntent.d.ts +41 -0
- package/dist/esm/review/writeIntent.d.ts.map +1 -0
- package/dist/esm/review/writeIntent.js +73 -0
- package/dist/esm/review/writeIntent.js.map +1 -0
- package/dist/esm/review/writeRecovery.d.ts +21 -0
- package/dist/esm/review/writeRecovery.d.ts.map +1 -1
- package/dist/esm/review/writeRecovery.js +65 -0
- package/dist/esm/review/writeRecovery.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { isAddress, toEventSelector, } from "viem";
|
|
2
2
|
import { canonicalSafeTxHash, lookAtSafeProposalEnd, provenSafeProposalEnd, readSafeTransaction, SAFE_PROPOSAL_AWAITING, safeTransactionMessage, } from "../safeService.js";
|
|
3
|
-
import { SAFE_PROPOSAL_EXECUTED_LINE, WRITE_HOLD_HASHED_EXIT_LINE, WRITE_HOLD_IN_TAB_LINE, WRITE_HOLD_LOOKS_DONE_LINE, WRITE_HOLD_MINED_LINE, WRITE_HOLD_NONCE_LINE, WRITE_HOLD_PENDING_LINE, WRITE_HOLD_UNCHECKED_LINE, WRITE_HOLD_REVERTED_LINE, WRITE_HOLD_SAFE_REPLACED_LINE, WRITE_HOLD_SAFE_UNPROVEN_LINE, writeHeldMessage, } from "./messages.js";
|
|
3
|
+
import { SAFE_PROPOSAL_EXECUTED_LINE, WRITE_HOLD_HASHED_EXIT_LINE, WRITE_HOLD_IN_TAB_LINE, WRITE_HOLD_LOOKS_DONE_LINE, WRITE_HOLD_MINED_LINE, WRITE_HOLD_NONCE_LINE, WRITE_HOLD_PENDING_LINE, WRITE_HOLD_UNCHECKED_LINE, WRITE_HOLD_REVERTED_LINE, WRITE_HOLD_SAFE_REPLACED_LINE, WRITE_HOLD_SAFE_UNPROVEN_LINE, WRITE_HOLD_STALE_REVIEW_LINE, writeHeldMessage, } from "./messages.js";
|
|
4
4
|
import { createBrowserWriteRecovery, createHeldWriteStorage, findTransactionAtNonce, readReviewedWriteNonce, SubmittedWritePersistenceError, verifyReviewedWriteExpiry, verifyReviewedWriteReceipt, } from "./writeRecovery.js";
|
|
5
5
|
import { isBytes32 } from "../untrusted.js";
|
|
6
6
|
import { atCanonicalFinalizedBlock } from "./relayr.js";
|
|
7
|
+
/**
|
|
8
|
+
* A state read without the chain (peek, a replay, another tab's change, a
|
|
9
|
+
* send's progress): its status and line, never the exit. Only the gate in
|
|
10
|
+
* state() and exit() offers that.
|
|
11
|
+
*/
|
|
12
|
+
function ungated(state) {
|
|
13
|
+
return state.status === "clear" || !state.exit
|
|
14
|
+
? state
|
|
15
|
+
: { ...state, exit: false };
|
|
16
|
+
}
|
|
7
17
|
/** A send was refused because the exact call is held; `state` says how. */
|
|
8
18
|
export class WriteHeldError extends Error {
|
|
9
19
|
constructor(state) {
|
|
@@ -20,6 +30,18 @@ export class WriteHoldNonceError extends Error {
|
|
|
20
30
|
this.name = "WriteHoldNonceError";
|
|
21
31
|
}
|
|
22
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The review went stale: another window's attempt of the same write was
|
|
35
|
+
* reserved, and maybe settled (`settled`), while it was open. Review again;
|
|
36
|
+
* the next review sends normally.
|
|
37
|
+
*/
|
|
38
|
+
export class WriteHoldStaleReviewError extends Error {
|
|
39
|
+
constructor(settled) {
|
|
40
|
+
super(WRITE_HOLD_STALE_REVIEW_LINE);
|
|
41
|
+
this.settled = settled;
|
|
42
|
+
this.name = "WriteHoldStaleReviewError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
23
45
|
/** The exact call is already being sent from another flow or tab. */
|
|
24
46
|
export class WriteHoldBusyError extends Error {
|
|
25
47
|
constructor() {
|
|
@@ -28,6 +50,8 @@ export class WriteHoldBusyError extends Error {
|
|
|
28
50
|
}
|
|
29
51
|
}
|
|
30
52
|
const SETTLE_POLL_MS = 15_000;
|
|
53
|
+
/** How long a reservation retries a lock another writer briefly holds. */
|
|
54
|
+
const RESERVE_RETRY_MS = 2_000;
|
|
31
55
|
/** A Safe's execution events, in its 1.3 and 1.4 layouts alike. */
|
|
32
56
|
const SAFE_EXECUTION_TOPICS = [
|
|
33
57
|
toEventSelector("ExecutionSuccess(bytes32,uint256)"),
|
|
@@ -82,8 +106,12 @@ function unchecked(record) {
|
|
|
82
106
|
}
|
|
83
107
|
/** About an hour of polls; a later state() looks again. */
|
|
84
108
|
const SETTLE_POLL_ATTEMPTS = 240;
|
|
85
|
-
function clear(settled) {
|
|
86
|
-
return
|
|
109
|
+
function clear(settled, reservation) {
|
|
110
|
+
return {
|
|
111
|
+
status: "clear",
|
|
112
|
+
...(settled ? { settled } : {}),
|
|
113
|
+
...(reservation === undefined ? {} : { reservation }),
|
|
114
|
+
};
|
|
87
115
|
}
|
|
88
116
|
function ranExecuted(record, executionHash = record.executionHash, result) {
|
|
89
117
|
return {
|
|
@@ -153,6 +181,17 @@ export function createWriteHold(options = {}) {
|
|
|
153
181
|
const peekers = new Map();
|
|
154
182
|
const timers = new Map();
|
|
155
183
|
let onStorage;
|
|
184
|
+
/**
|
|
185
|
+
* The one decorator every state leaves through for its storage notice:
|
|
186
|
+
* while holds last in this tab alone, a held state carries
|
|
187
|
+
* {@link WRITE_HOLD_IN_TAB_LINE}; otherwise none.
|
|
188
|
+
*/
|
|
189
|
+
function noticed(state) {
|
|
190
|
+
const { notice: _, ...rest } = state;
|
|
191
|
+
return state.status !== "clear" && !storage.durable()
|
|
192
|
+
? { ...rest, notice: WRITE_HOLD_IN_TAB_LINE }
|
|
193
|
+
: rest;
|
|
194
|
+
}
|
|
156
195
|
/** The latest state emitted for each call, replayed to a late subscriber. */
|
|
157
196
|
const latest = new Map();
|
|
158
197
|
function emit(key, state) {
|
|
@@ -165,7 +204,9 @@ export function createWriteHold(options = {}) {
|
|
|
165
204
|
if (onStorage || typeof globalThis.addEventListener !== "function")
|
|
166
205
|
return;
|
|
167
206
|
onStorage = (event) => {
|
|
168
|
-
if (event.key &&
|
|
207
|
+
if (event.key &&
|
|
208
|
+
(!event.key.startsWith("nana-sdk:reviewed-write") ||
|
|
209
|
+
event.key === "nana-sdk:reviewed-write:probe"))
|
|
169
210
|
return;
|
|
170
211
|
for (const [key, peek] of peekers) {
|
|
171
212
|
try {
|
|
@@ -178,7 +219,7 @@ export function createWriteHold(options = {}) {
|
|
|
178
219
|
};
|
|
179
220
|
globalThis.addEventListener("storage", onStorage);
|
|
180
221
|
}
|
|
181
|
-
function
|
|
222
|
+
function openOne(call, hooks = {}) {
|
|
182
223
|
const recovery = createBrowserWriteRecovery(call, {
|
|
183
224
|
storage,
|
|
184
225
|
locks: options.locks,
|
|
@@ -189,11 +230,26 @@ export function createWriteHold(options = {}) {
|
|
|
189
230
|
let lastEffect;
|
|
190
231
|
/** Nothing held: how the call's last write settled, if saved. */
|
|
191
232
|
const cleared = () => clear(recovery.settled());
|
|
233
|
+
/** Every state leaves with the reservation it belongs to. */
|
|
234
|
+
const tagged = (state) => {
|
|
235
|
+
const reservation = state.reservation ??
|
|
236
|
+
(state.status === "clear"
|
|
237
|
+
? recovery.latestReservation()
|
|
238
|
+
: state.record.id);
|
|
239
|
+
return noticed({
|
|
240
|
+
...state,
|
|
241
|
+
...(reservation === undefined ? {} : { reservation }),
|
|
242
|
+
});
|
|
243
|
+
};
|
|
192
244
|
const peek = () => {
|
|
193
245
|
const record = recovery.read();
|
|
194
|
-
return record ? classify(record) : cleared();
|
|
246
|
+
return tagged(record ? ungated(classify(record)) : cleared());
|
|
247
|
+
};
|
|
248
|
+
const shown = (state) => {
|
|
249
|
+
const out = tagged(state);
|
|
250
|
+
emit(key, out);
|
|
251
|
+
return out;
|
|
195
252
|
};
|
|
196
|
-
const shown = (state) => (emit(key, state), state);
|
|
197
253
|
async function locked(work, wait) {
|
|
198
254
|
let entered = false;
|
|
199
255
|
try {
|
|
@@ -242,7 +298,7 @@ export function createWriteHold(options = {}) {
|
|
|
242
298
|
};
|
|
243
299
|
// Kept for any later reader: another tab, a reload, a follow timer.
|
|
244
300
|
recovery.markSettled(record, settled);
|
|
245
|
-
return clear(settled);
|
|
301
|
+
return clear(settled, record.id);
|
|
246
302
|
}
|
|
247
303
|
if (!record.safe &&
|
|
248
304
|
receipt.status === "reverted" &&
|
|
@@ -334,8 +390,22 @@ export function createWriteHold(options = {}) {
|
|
|
334
390
|
// Only positive pending evidence stops the clock; a read that fails
|
|
335
391
|
// keeps the one saved.
|
|
336
392
|
if (look === "pending")
|
|
337
|
-
return {
|
|
393
|
+
return {
|
|
394
|
+
...state,
|
|
395
|
+
record: recovery.seenPending(recovery.mined(state.record, false)),
|
|
396
|
+
};
|
|
338
397
|
const record = look === "mined" ? recovery.mined(state.record, true) : state.record;
|
|
398
|
+
// Unknown or not found is not pending: with no read showing it pending
|
|
399
|
+
// or mined for 30 minutes (since it was last seen pending, or else
|
|
400
|
+
// submitted), the warned explicit exit, never a release on its own. A
|
|
401
|
+
// Safe proposal's hash isn't a transaction to read.
|
|
402
|
+
if ((look === "unknown" || look === null) &&
|
|
403
|
+
!record.safe &&
|
|
404
|
+
record.minedAt === undefined &&
|
|
405
|
+
Date.now() -
|
|
406
|
+
Number(record.pendingSeenAt ?? record.submittedAt ?? record.firstSeenAt) >=
|
|
407
|
+
UNSEEN_HASH_MS)
|
|
408
|
+
return unchecked(record);
|
|
339
409
|
if (record.minedAt === undefined)
|
|
340
410
|
return state;
|
|
341
411
|
if (Date.now() - Number(record.minedAt) >= UNSEEN_HASH_MS)
|
|
@@ -344,7 +414,9 @@ export function createWriteHold(options = {}) {
|
|
|
344
414
|
}
|
|
345
415
|
/** What the node shows of `hash`: positive pending or mined metadata, or nothing it can say. */
|
|
346
416
|
async function lookAt(hash, client) {
|
|
347
|
-
const sent = await client.getTransaction({ hash }).
|
|
417
|
+
const sent = await client.getTransaction({ hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
|
|
418
|
+
if (sent === undefined)
|
|
419
|
+
return "unknown";
|
|
348
420
|
if (sent?.hash?.toLowerCase() !== hash)
|
|
349
421
|
return null;
|
|
350
422
|
return sent.blockNumber === null ? "pending" : "mined";
|
|
@@ -365,7 +437,8 @@ export function createWriteHold(options = {}) {
|
|
|
365
437
|
for (const hash of seen) {
|
|
366
438
|
const own = hash === record.hash;
|
|
367
439
|
const look = await lookAt(hash, client);
|
|
368
|
-
|
|
440
|
+
// Unknown changes nothing: only positive metadata moves a clock.
|
|
441
|
+
if (look === "pending" || look === "mined")
|
|
369
442
|
record = own
|
|
370
443
|
? recovery.mined(record, look === "mined")
|
|
371
444
|
: recovery.candidateMined(record, hash, look === "mined");
|
|
@@ -463,7 +536,7 @@ export function createWriteHold(options = {}) {
|
|
|
463
536
|
if (end.look === "expired" && !record.executed) {
|
|
464
537
|
const expired = await verifyReviewedWriteExpiry(client, record, options.safeService).catch(() => false);
|
|
465
538
|
if (expired && release(record))
|
|
466
|
-
return clear({ result: "expired" });
|
|
539
|
+
return clear({ result: "expired" }, record.id);
|
|
467
540
|
}
|
|
468
541
|
if (end.look === "executed") {
|
|
469
542
|
if (!record.executed ||
|
|
@@ -485,7 +558,7 @@ export function createWriteHold(options = {}) {
|
|
|
485
558
|
hash: end.execution.executionHash,
|
|
486
559
|
};
|
|
487
560
|
recovery.markSettled(record, settled);
|
|
488
|
-
return clear(settled);
|
|
561
|
+
return clear(settled, record.id);
|
|
489
562
|
}
|
|
490
563
|
}
|
|
491
564
|
if (!record.executed &&
|
|
@@ -512,10 +585,23 @@ export function createWriteHold(options = {}) {
|
|
|
512
585
|
// canonical finalized block) can't run, whatever a node still shows.
|
|
513
586
|
return ((await seenGate(state.record, client, state.status === "dropped-proven")) ?? state);
|
|
514
587
|
}
|
|
588
|
+
/**
|
|
589
|
+
* The way out of every method that has a client: a state that could
|
|
590
|
+
* offer the exit, or track a hash, is the gate's answer (inspect), so no
|
|
591
|
+
* method reports an exit state() would refuse. Under the call's lock.
|
|
592
|
+
*/
|
|
593
|
+
async function finish(state, client) {
|
|
594
|
+
return state.status === "clear" || state.status === "ran-executed"
|
|
595
|
+
? state
|
|
596
|
+
: inspect(client);
|
|
597
|
+
}
|
|
515
598
|
async function inspectChain(client, effect) {
|
|
516
599
|
let record = recovery.read();
|
|
517
600
|
if (!record)
|
|
518
601
|
return cleared();
|
|
602
|
+
// A mirror carries no evidence of its own: its source attempt does.
|
|
603
|
+
if (record.mirrorOf)
|
|
604
|
+
return classify(record);
|
|
519
605
|
// No evidence from another chain.
|
|
520
606
|
if ((await client.getChainId().catch(() => null)) !== record.chainId)
|
|
521
607
|
return classify(record);
|
|
@@ -617,8 +703,11 @@ export function createWriteHold(options = {}) {
|
|
|
617
703
|
if (last) {
|
|
618
704
|
const now = peek();
|
|
619
705
|
// A clear is compared by its settlement's identity, a hold by its record.
|
|
620
|
-
const identity = (state) => JSON.stringify(
|
|
621
|
-
|
|
706
|
+
const identity = (state) => JSON.stringify([
|
|
707
|
+
state.reservation ?? null,
|
|
708
|
+
state.status === "clear" ? (state.settled ?? null) : state.record,
|
|
709
|
+
]);
|
|
710
|
+
listener(noticed(ungated(identity(now) === identity(last) ? last : now)));
|
|
622
711
|
}
|
|
623
712
|
return () => {
|
|
624
713
|
set.delete(listener);
|
|
@@ -637,6 +726,10 @@ export function createWriteHold(options = {}) {
|
|
|
637
726
|
*/
|
|
638
727
|
async send(client, run) {
|
|
639
728
|
let attempt;
|
|
729
|
+
let ended;
|
|
730
|
+
const until = new Promise((resolve) => (ended = resolve));
|
|
731
|
+
let released;
|
|
732
|
+
let reviewed = "";
|
|
640
733
|
const gate = {
|
|
641
734
|
async beforeSend(actualCall) {
|
|
642
735
|
let evidence;
|
|
@@ -650,11 +743,31 @@ export function createWriteHold(options = {}) {
|
|
|
650
743
|
throw new WriteHoldNonceError(cause);
|
|
651
744
|
}
|
|
652
745
|
}
|
|
653
|
-
|
|
746
|
+
const reserve = () => {
|
|
654
747
|
attempt = recovery.reserve(actualCall, evidence);
|
|
748
|
+
};
|
|
749
|
+
try {
|
|
750
|
+
if (hooks.across)
|
|
751
|
+
({ released } = await hooks.across((work) => recovery.withLock(work), reserve, () => {
|
|
752
|
+
recovery.rejected();
|
|
753
|
+
attempt = undefined;
|
|
754
|
+
}, until, reviewed));
|
|
755
|
+
else
|
|
756
|
+
reserve();
|
|
655
757
|
}
|
|
656
758
|
catch (error) {
|
|
657
|
-
|
|
759
|
+
if (error instanceof WriteHeldError ||
|
|
760
|
+
error instanceof WriteHoldBusyError ||
|
|
761
|
+
error instanceof WriteHoldStaleReviewError)
|
|
762
|
+
throw error;
|
|
763
|
+
if (hooks.across) {
|
|
764
|
+
// No lock is held here: what the saved record says.
|
|
765
|
+
const now = shown(peek());
|
|
766
|
+
if (now.status !== "clear")
|
|
767
|
+
throw new WriteHeldError(now);
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
const now = shown(await finish(classify(recovery.read()), client));
|
|
658
771
|
if (now.status !== "clear")
|
|
659
772
|
throw new WriteHeldError(now);
|
|
660
773
|
throw error;
|
|
@@ -681,7 +794,7 @@ export function createWriteHold(options = {}) {
|
|
|
681
794
|
catch {
|
|
682
795
|
/* Not this proposal: the service is asked later. */
|
|
683
796
|
}
|
|
684
|
-
shown(classify(attempt));
|
|
797
|
+
shown(ungated(classify(attempt)));
|
|
685
798
|
},
|
|
686
799
|
onRejected() {
|
|
687
800
|
recovery.rejected();
|
|
@@ -689,6 +802,25 @@ export function createWriteHold(options = {}) {
|
|
|
689
802
|
shown(peek());
|
|
690
803
|
},
|
|
691
804
|
};
|
|
805
|
+
if (hooks.across) {
|
|
806
|
+
// Checked under this key's lock, which is then let go: the final
|
|
807
|
+
// check and reservation take every lock again, in order.
|
|
808
|
+
const checked = await locked(async () => shown(await inspect(client)), false);
|
|
809
|
+
if (!checked)
|
|
810
|
+
throw new WriteHoldBusyError();
|
|
811
|
+
if (checked.value.status !== "clear")
|
|
812
|
+
throw new WriteHeldError(checked.value);
|
|
813
|
+
// Given with `across`: the generations the review is checked against.
|
|
814
|
+
reviewed = hooks.generations();
|
|
815
|
+
try {
|
|
816
|
+
return await run(gate);
|
|
817
|
+
}
|
|
818
|
+
finally {
|
|
819
|
+
// The send ends: every lock it reserved under is let go first.
|
|
820
|
+
ended();
|
|
821
|
+
await released;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
692
824
|
const result = await locked(async () => {
|
|
693
825
|
const now = shown(await inspect(client));
|
|
694
826
|
if (now.status !== "clear")
|
|
@@ -720,13 +852,20 @@ export function createWriteHold(options = {}) {
|
|
|
720
852
|
recovery.abandon(snapshot.record, { checkedWallet: true });
|
|
721
853
|
}
|
|
722
854
|
catch {
|
|
723
|
-
return {
|
|
855
|
+
return {
|
|
856
|
+
released: false,
|
|
857
|
+
state: await finish(classify(recovery.read()), client),
|
|
858
|
+
};
|
|
724
859
|
}
|
|
725
|
-
return {
|
|
726
|
-
|
|
860
|
+
return {
|
|
861
|
+
released: true,
|
|
862
|
+
state: clear(undefined, snapshot.record.id),
|
|
863
|
+
};
|
|
864
|
+
// Never waits behind another send's wallet prompt: on contention,
|
|
865
|
+
// what the saved record says, with no exit.
|
|
866
|
+
}, false);
|
|
727
867
|
const ended = result?.value ?? { released: false, state: peek() };
|
|
728
|
-
shown(ended.state);
|
|
729
|
-
return ended;
|
|
868
|
+
return { ...ended, state: shown(ended.state) };
|
|
730
869
|
},
|
|
731
870
|
/**
|
|
732
871
|
* A receipt for the held write: an ordinary write's own hash, or a Safe
|
|
@@ -737,10 +876,11 @@ export function createWriteHold(options = {}) {
|
|
|
737
876
|
const result = await locked(async () => {
|
|
738
877
|
const record = recovery.read();
|
|
739
878
|
if (!record ||
|
|
879
|
+
record.mirrorOf ||
|
|
740
880
|
(!record.safe &&
|
|
741
881
|
record.hash !== receipt?.transactionHash?.toLowerCase()))
|
|
742
|
-
return classify(record);
|
|
743
|
-
return settle(record, receipt, client, proof);
|
|
882
|
+
return finish(classify(record), client);
|
|
883
|
+
return finish(await settle(record, receipt, client, proof), client);
|
|
744
884
|
}, true);
|
|
745
885
|
return shown(result?.value ?? peek());
|
|
746
886
|
},
|
|
@@ -775,6 +915,7 @@ export function createWriteHold(options = {}) {
|
|
|
775
915
|
if (record &&
|
|
776
916
|
!record.safe &&
|
|
777
917
|
!record.hash &&
|
|
918
|
+
!record.mirrorOf &&
|
|
778
919
|
isBytes32(hash) &&
|
|
779
920
|
(await client.getChainId().catch(() => null)) === record.chainId) {
|
|
780
921
|
const sent = await client
|
|
@@ -816,7 +957,7 @@ export function createWriteHold(options = {}) {
|
|
|
816
957
|
const result = await locked(async () => {
|
|
817
958
|
const record = recovery.read();
|
|
818
959
|
if (!record?.safe || !record.hash)
|
|
819
|
-
return classify(record);
|
|
960
|
+
return finish(classify(record), client);
|
|
820
961
|
if (executionHash) {
|
|
821
962
|
// Authenticated on arrival: its own receipt settles it, or proves
|
|
822
963
|
// it isn't this write's; only an unresolved one is kept.
|
|
@@ -838,6 +979,278 @@ export function createWriteHold(options = {}) {
|
|
|
838
979
|
},
|
|
839
980
|
};
|
|
840
981
|
}
|
|
982
|
+
/**
|
|
983
|
+
* `legacy`: for a write held under an intent namespace, the keys an earlier
|
|
984
|
+
* version held it under (an old namespace, or `{ call }` for an exact
|
|
985
|
+
* call); the current exact call is always checked. While any of them holds
|
|
986
|
+
* an unresolved write, the intent handle *is* that hold: its state, its
|
|
987
|
+
* refusal and its own exit, in place. Each is checked alone, under its own
|
|
988
|
+
* lock only, never two at once. Only when all are clear does the intent's
|
|
989
|
+
* own record apply, and only then can a send reserve; they are checked
|
|
990
|
+
* again right before it does.
|
|
991
|
+
*/
|
|
992
|
+
function open(call, { legacy = [], } = {}) {
|
|
993
|
+
if (call.namespace === undefined)
|
|
994
|
+
return openExact(call);
|
|
995
|
+
const namespace = call.namespace;
|
|
996
|
+
const own = openOne(call, {
|
|
997
|
+
across: reserveAcross,
|
|
998
|
+
generations: () => generations(),
|
|
999
|
+
});
|
|
1000
|
+
const ownJournal = createBrowserWriteRecovery(call, { storage });
|
|
1001
|
+
/** Every involved key's latest reservation id, its generation. */
|
|
1002
|
+
const generations = () => JSON.stringify([ownJournal, ...journals].map((journal) => journal.latestReservation()));
|
|
1003
|
+
const { namespace: _, ...exact } = call;
|
|
1004
|
+
const scopeOf = (input) => createBrowserWriteRecovery(input, { storage }).scopeKey;
|
|
1005
|
+
const scopes = new Set([scopeOf(call)]);
|
|
1006
|
+
const inputs = [{ call: call.call }, ...legacy]
|
|
1007
|
+
.map((source) => typeof source === "string"
|
|
1008
|
+
? { ...call, namespace: source }
|
|
1009
|
+
: { ...exact, call: source.call })
|
|
1010
|
+
.filter((input) => {
|
|
1011
|
+
const scope = scopeOf(input);
|
|
1012
|
+
return !scopes.has(scope) && !!scopes.add(scope);
|
|
1013
|
+
})
|
|
1014
|
+
// One canonical order, by scope, for every writer's locks.
|
|
1015
|
+
.sort((a, b) => Number(scopeOf(a) > scopeOf(b)) - Number(scopeOf(a) < scopeOf(b)));
|
|
1016
|
+
// Mirror-aware: evidence and exits always act on the source attempt.
|
|
1017
|
+
const delegates = inputs.map((input) => openExact(input));
|
|
1018
|
+
const journals = inputs.map((input) => createBrowserWriteRecovery(input, { storage, locks: options.locks }));
|
|
1019
|
+
/** A legacy key holding anything but this intent's own mirror. */
|
|
1020
|
+
const foreign = (index) => journals[index].read()?.mirrorOf !== namespace &&
|
|
1021
|
+
delegates[index].peek().status !== "clear";
|
|
1022
|
+
/** The legacy hold the intent stands for right now, from storage alone. */
|
|
1023
|
+
const active = () => delegates.find((_, index) => foreign(index));
|
|
1024
|
+
/** The first legacy key holding an unresolved write, through its own gate. */
|
|
1025
|
+
async function delegated(client) {
|
|
1026
|
+
for (const [index, handle] of delegates.entries()) {
|
|
1027
|
+
if (!foreign(index))
|
|
1028
|
+
continue;
|
|
1029
|
+
const state = await handle.state(client);
|
|
1030
|
+
if (state.status !== "clear")
|
|
1031
|
+
return state;
|
|
1032
|
+
}
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* The final check and reservation, under every legacy key's lock taken
|
|
1037
|
+
* in one sorted order (the intent's own is held by its send), each
|
|
1038
|
+
* without waiting: a key another writer holds refuses as busy. A legacy
|
|
1039
|
+
* hold refuses; otherwise the intent reserves, and mirrors itself at each
|
|
1040
|
+
* legacy key so its readers, older versions included, see it held.
|
|
1041
|
+
*/
|
|
1042
|
+
async function reserveAcross(ownLock, reserve, unreserve, until, reviewed) {
|
|
1043
|
+
const locks = [
|
|
1044
|
+
{ scope: scopeOf(call), take: ownLock },
|
|
1045
|
+
...journals.map((journal) => ({
|
|
1046
|
+
scope: journal.scopeKey,
|
|
1047
|
+
take: (work) => journal.withLock(work),
|
|
1048
|
+
})),
|
|
1049
|
+
].sort((a, b) => Number(a.scope > b.scope) - Number(a.scope < b.scope));
|
|
1050
|
+
const deadline = Date.now() + RESERVE_RETRY_MS;
|
|
1051
|
+
for (;;) {
|
|
1052
|
+
let entered = 0;
|
|
1053
|
+
let reserved;
|
|
1054
|
+
const done = new Promise((resolve) => (reserved = resolve));
|
|
1055
|
+
const acquire = async (index) => {
|
|
1056
|
+
if (index < locks.length)
|
|
1057
|
+
return locks[index].take(async () => {
|
|
1058
|
+
entered += 1;
|
|
1059
|
+
return acquire(index + 1);
|
|
1060
|
+
});
|
|
1061
|
+
for (const journal of journals) {
|
|
1062
|
+
const held = journal.read();
|
|
1063
|
+
if (held && held.mirrorOf !== namespace)
|
|
1064
|
+
throw new WriteHeldError(noticed({
|
|
1065
|
+
...ungated(classify(held)),
|
|
1066
|
+
reservation: held.id,
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
1069
|
+
// Another attempt since this review opened: review it again.
|
|
1070
|
+
if (generations() !== reviewed)
|
|
1071
|
+
throw new WriteHoldStaleReviewError(ownJournal.settled());
|
|
1072
|
+
reserve();
|
|
1073
|
+
const record = own.peek().record;
|
|
1074
|
+
// Every mirror is saved, in the store the hold itself uses (its
|
|
1075
|
+
// memory fallback included), before the wallet opens; otherwise
|
|
1076
|
+
// this unsent reservation and its partial mirrors are undone.
|
|
1077
|
+
const mirrors = [];
|
|
1078
|
+
try {
|
|
1079
|
+
for (const journal of journals)
|
|
1080
|
+
mirrors.push([journal, journal.mirror(record, namespace)]);
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
for (const [journal, mirror] of mirrors)
|
|
1084
|
+
try {
|
|
1085
|
+
journal.clear(mirror);
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
/* Held for old readers until their explicit exit. */
|
|
1089
|
+
}
|
|
1090
|
+
try {
|
|
1091
|
+
unreserve();
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
/* Unsent, it stays held with its explicit exit. */
|
|
1095
|
+
}
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
// Reserved: every lock is kept until the send ends.
|
|
1099
|
+
reserved();
|
|
1100
|
+
await until;
|
|
1101
|
+
};
|
|
1102
|
+
const holding = acquire(0);
|
|
1103
|
+
const failed = await Promise.race([
|
|
1104
|
+
done.then(() => null),
|
|
1105
|
+
holding.then(() => null, (error) => ({ error })),
|
|
1106
|
+
]);
|
|
1107
|
+
if (!failed)
|
|
1108
|
+
return { released: holding.catch(() => { }) };
|
|
1109
|
+
// Not reserved: every lock, its own included, is let go before any
|
|
1110
|
+
// retry; the checks run again each time.
|
|
1111
|
+
const { error } = failed;
|
|
1112
|
+
if (entered === locks.length ||
|
|
1113
|
+
error instanceof WriteHeldError ||
|
|
1114
|
+
error instanceof WriteHoldStaleReviewError)
|
|
1115
|
+
throw error;
|
|
1116
|
+
if (Date.now() >= deadline)
|
|
1117
|
+
throw new WriteHoldBusyError();
|
|
1118
|
+
await new Promise((resolve) => setTimeout(resolve, 25 + Math.random() * 75));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
/** Remove this intent's mirrors of the reservation that ended, by its id only. */
|
|
1122
|
+
async function clearMirrors(ended) {
|
|
1123
|
+
for (const journal of journals) {
|
|
1124
|
+
if (journal.read()?.id !== ended)
|
|
1125
|
+
continue;
|
|
1126
|
+
await journal
|
|
1127
|
+
.withLock(async () => {
|
|
1128
|
+
const current = journal.read();
|
|
1129
|
+
if (current?.mirrorOf === namespace && current.id === ended)
|
|
1130
|
+
journal.clear(current);
|
|
1131
|
+
})
|
|
1132
|
+
.catch(() => {
|
|
1133
|
+
/* Kept for old readers until their explicit exit. */
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
/** After a call that ended this intent's reservation, its mirrors go. */
|
|
1138
|
+
const after = async (value) => {
|
|
1139
|
+
if (value.status === "clear" && value.reservation)
|
|
1140
|
+
await clearMirrors(value.reservation);
|
|
1141
|
+
return value;
|
|
1142
|
+
};
|
|
1143
|
+
return {
|
|
1144
|
+
...own,
|
|
1145
|
+
peek: () => active()?.peek() ?? own.peek(),
|
|
1146
|
+
async state(client, checks) {
|
|
1147
|
+
return ((await delegated(client)) ?? after(await own.state(client, checks)));
|
|
1148
|
+
},
|
|
1149
|
+
async send(client, run) {
|
|
1150
|
+
const held = await delegated(client);
|
|
1151
|
+
if (held)
|
|
1152
|
+
throw new WriteHeldError(held);
|
|
1153
|
+
return own.send(client, run);
|
|
1154
|
+
},
|
|
1155
|
+
async exit(snapshot, acknowledgement, client) {
|
|
1156
|
+
const owner = snapshot.status === "clear"
|
|
1157
|
+
? undefined
|
|
1158
|
+
: delegates.find((handle, index) => {
|
|
1159
|
+
const now = handle.peek();
|
|
1160
|
+
return (foreign(index) &&
|
|
1161
|
+
now.status !== "clear" &&
|
|
1162
|
+
now.record.id === snapshot.record.id);
|
|
1163
|
+
});
|
|
1164
|
+
if (owner)
|
|
1165
|
+
return owner.exit(snapshot, acknowledgement, client);
|
|
1166
|
+
const held = await delegated(client);
|
|
1167
|
+
if (held)
|
|
1168
|
+
return { released: false, state: held };
|
|
1169
|
+
const ended = await own.exit(snapshot, acknowledgement, client);
|
|
1170
|
+
await after(ended.state);
|
|
1171
|
+
return ended;
|
|
1172
|
+
},
|
|
1173
|
+
onReceipt: async (...args) => active()?.onReceipt(...args) ?? after(await own.onReceipt(...args)),
|
|
1174
|
+
onUnproven: async (...args) => active()?.onUnproven(...args) ?? after(await own.onUnproven(...args)),
|
|
1175
|
+
onExecuted: async (...args) => active()?.onExecuted(...args) ?? after(await own.onExecuted(...args)),
|
|
1176
|
+
attachHash: async (...args) => active()?.attachHash(...args) ?? after(await own.attachHash(...args)),
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* An exact-call handle. When its key holds a mirror of an intent's write,
|
|
1181
|
+
* it is that intent's hold: it never sends or releases on its own, and its
|
|
1182
|
+
* exit is the intent's. A mirror left after the intent ended is removed.
|
|
1183
|
+
*/
|
|
1184
|
+
function openExact(call) {
|
|
1185
|
+
const own = openOne(call);
|
|
1186
|
+
const journal = createBrowserWriteRecovery(call, {
|
|
1187
|
+
storage,
|
|
1188
|
+
locks: options.locks,
|
|
1189
|
+
});
|
|
1190
|
+
/** The intent this key mirrors, from storage alone. */
|
|
1191
|
+
const mirrored = () => {
|
|
1192
|
+
const record = journal.read();
|
|
1193
|
+
return record?.mirrorOf
|
|
1194
|
+
? { record, intent: openOne({ ...call, namespace: record.mirrorOf }) }
|
|
1195
|
+
: undefined;
|
|
1196
|
+
};
|
|
1197
|
+
/** Remove a mirror whose intent holds nothing any more. */
|
|
1198
|
+
async function clearStale(found) {
|
|
1199
|
+
await journal
|
|
1200
|
+
.withLock(async () => {
|
|
1201
|
+
const current = journal.read();
|
|
1202
|
+
if (current?.id === found.record.id)
|
|
1203
|
+
journal.clear(current);
|
|
1204
|
+
})
|
|
1205
|
+
.catch(() => {
|
|
1206
|
+
/* The stale mirror still holds; its intent is clear. */
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
/** The intent's state while it holds anything: this key is its mirror. */
|
|
1210
|
+
async function intentState(client) {
|
|
1211
|
+
const found = mirrored();
|
|
1212
|
+
if (!found)
|
|
1213
|
+
return null;
|
|
1214
|
+
const state = await found.intent.state(client);
|
|
1215
|
+
if (state.status !== "clear")
|
|
1216
|
+
return state;
|
|
1217
|
+
await clearStale(found);
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
/** The intent in charge, from storage alone. */
|
|
1221
|
+
const route = () => {
|
|
1222
|
+
const found = mirrored();
|
|
1223
|
+
return found && found.intent.peek().status !== "clear"
|
|
1224
|
+
? found.intent
|
|
1225
|
+
: undefined;
|
|
1226
|
+
};
|
|
1227
|
+
return {
|
|
1228
|
+
...own,
|
|
1229
|
+
peek: () => route()?.peek() ?? own.peek(),
|
|
1230
|
+
async state(client, checks) {
|
|
1231
|
+
return (await intentState(client)) ?? own.state(client, checks);
|
|
1232
|
+
},
|
|
1233
|
+
async send(client, run) {
|
|
1234
|
+
const held = await intentState(client);
|
|
1235
|
+
if (held)
|
|
1236
|
+
throw new WriteHeldError(held);
|
|
1237
|
+
return own.send(client, run);
|
|
1238
|
+
},
|
|
1239
|
+
async exit(snapshot, acknowledgement, client) {
|
|
1240
|
+
const found = mirrored();
|
|
1241
|
+
if (!found || found.intent.peek().status === "clear")
|
|
1242
|
+
return own.exit(snapshot, acknowledgement, client);
|
|
1243
|
+
const ended = await found.intent.exit(snapshot, acknowledgement, client);
|
|
1244
|
+
if (ended.released)
|
|
1245
|
+
await clearStale(found);
|
|
1246
|
+
return ended;
|
|
1247
|
+
},
|
|
1248
|
+
onReceipt: (...args) => (route() ?? own).onReceipt(...args),
|
|
1249
|
+
onUnproven: (...args) => (route() ?? own).onUnproven(...args),
|
|
1250
|
+
onExecuted: (...args) => (route() ?? own).onExecuted(...args),
|
|
1251
|
+
attachHash: (...args) => (route() ?? own).attachHash(...args),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
841
1254
|
return {
|
|
842
1255
|
open,
|
|
843
1256
|
/** Whether holds outlive this tab right now. */
|