@bananapus/nana-sdk-core 2.34.1 → 2.36.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/safeRelayr.d.ts.map +1 -1
- package/dist/cjs/review/safeRelayr.js +42 -24
- package/dist/cjs/review/safeRelayr.js.map +1 -1
- package/dist/cjs/review/tabState.d.ts +23 -0
- package/dist/cjs/review/tabState.d.ts.map +1 -0
- package/dist/cjs/review/tabState.js +30 -0
- package/dist/cjs/review/tabState.js.map +1 -0
- package/dist/cjs/review/writeHold.d.ts +74 -58
- package/dist/cjs/review/writeHold.d.ts.map +1 -1
- package/dist/cjs/review/writeHold.js +651 -43
- 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 +63 -3
- package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
- package/dist/cjs/review/writeRecovery.js +233 -14
- package/dist/cjs/review/writeRecovery.js.map +1 -1
- package/dist/cjs/testing.d.ts +7 -0
- package/dist/cjs/testing.d.ts.map +1 -0
- package/dist/cjs/testing.js +30 -0
- package/dist/cjs/testing.js.map +1 -0
- 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/safeRelayr.d.ts.map +1 -1
- package/dist/esm/review/safeRelayr.js +42 -24
- package/dist/esm/review/safeRelayr.js.map +1 -1
- package/dist/esm/review/tabState.d.ts +23 -0
- package/dist/esm/review/tabState.d.ts.map +1 -0
- package/dist/esm/review/tabState.js +26 -0
- package/dist/esm/review/tabState.js.map +1 -0
- package/dist/esm/review/writeHold.d.ts +74 -58
- package/dist/esm/review/writeHold.d.ts.map +1 -1
- package/dist/esm/review/writeHold.js +651 -44
- 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 +63 -3
- package/dist/esm/review/writeRecovery.d.ts.map +1 -1
- package/dist/esm/review/writeRecovery.js +232 -16
- package/dist/esm/review/writeRecovery.js.map +1 -1
- package/dist/esm/testing.d.ts +7 -0
- package/dist/esm/testing.d.ts.map +1 -0
- package/dist/esm/testing.js +25 -0
- package/dist/esm/testing.js.map +1 -0
- package/package.json +6 -1
|
@@ -1,9 +1,21 @@
|
|
|
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";
|
|
4
|
-
import { createBrowserWriteRecovery, createHeldWriteStorage, findTransactionAtNonce, readReviewedWriteNonce, SubmittedWritePersistenceError, verifyReviewedWriteExpiry, verifyReviewedWriteReceipt, } from "./writeRecovery.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
|
+
import { createBrowserWriteRecovery, createHeldWriteStorage, findReviewedWrites, heldSafeProposal, findTransactionAtNonce, readReviewedWriteNonce, safeProposalIsHeldCall, SubmittedWritePersistenceError, verifyReviewedWriteExpiry, verifyReviewedWriteReceipt, } from "./writeRecovery.js";
|
|
5
5
|
import { isBytes32 } from "../untrusted.js";
|
|
6
6
|
import { atCanonicalFinalizedBlock } from "./relayr.js";
|
|
7
|
+
import { TAB, tabShared } from "./tabState.js";
|
|
8
|
+
/** `sent` is the transaction at `hash`, exactly `record`'s call, from its account, on its chain. */
|
|
9
|
+
function sentIsCall(sent, hash, record) {
|
|
10
|
+
return (sent.hash?.toLowerCase() === hash.toLowerCase() &&
|
|
11
|
+
sent.from?.toLowerCase() === record.account &&
|
|
12
|
+
(sent.chainId === undefined || sent.chainId === record.chainId) &&
|
|
13
|
+
sent.to?.toLowerCase() === record.call.to &&
|
|
14
|
+
sent.input?.toLowerCase() === record.call.data &&
|
|
15
|
+
BigInt(sent.value) === BigInt(record.call.value));
|
|
16
|
+
}
|
|
17
|
+
/** The stop of every controller with a follower or tab listener running, for the test-only reset. */
|
|
18
|
+
const liveHolds = tabShared(TAB.holds, () => new Set());
|
|
7
19
|
/**
|
|
8
20
|
* A state read without the chain (peek, a replay, another tab's change, a
|
|
9
21
|
* send's progress): its status and line, never the exit. Only the gate in
|
|
@@ -30,6 +42,18 @@ export class WriteHoldNonceError extends Error {
|
|
|
30
42
|
this.name = "WriteHoldNonceError";
|
|
31
43
|
}
|
|
32
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The review went stale: another window's attempt of the same write was
|
|
47
|
+
* reserved, and maybe settled (`settled`), while it was open. Review again;
|
|
48
|
+
* the next review sends normally.
|
|
49
|
+
*/
|
|
50
|
+
export class WriteHoldStaleReviewError extends Error {
|
|
51
|
+
constructor(settled) {
|
|
52
|
+
super(WRITE_HOLD_STALE_REVIEW_LINE);
|
|
53
|
+
this.settled = settled;
|
|
54
|
+
this.name = "WriteHoldStaleReviewError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
33
57
|
/** The exact call is already being sent from another flow or tab. */
|
|
34
58
|
export class WriteHoldBusyError extends Error {
|
|
35
59
|
constructor() {
|
|
@@ -38,6 +62,8 @@ export class WriteHoldBusyError extends Error {
|
|
|
38
62
|
}
|
|
39
63
|
}
|
|
40
64
|
const SETTLE_POLL_MS = 15_000;
|
|
65
|
+
/** How long a reservation retries a lock another writer briefly holds. */
|
|
66
|
+
const RESERVE_RETRY_MS = 2_000;
|
|
41
67
|
/** A Safe's execution events, in its 1.3 and 1.4 layouts alike. */
|
|
42
68
|
const SAFE_EXECUTION_TOPICS = [
|
|
43
69
|
toEventSelector("ExecutionSuccess(bytes32,uint256)"),
|
|
@@ -45,6 +71,8 @@ const SAFE_EXECUTION_TOPICS = [
|
|
|
45
71
|
];
|
|
46
72
|
/** A hash the network still hasn't seen this long after the wallet returned it was almost certainly never broadcast. */
|
|
47
73
|
const UNSEEN_HASH_MS = 30 * 60_000;
|
|
74
|
+
/** The longest one request to Safe's service may take inside the hold. */
|
|
75
|
+
const SAFE_SERVICE_READ_MS = 8_000;
|
|
48
76
|
/** The 30 minutes have passed since the wallet returned the hash, or since it was first read. A clock that couldn't be saved (NaN) never runs out. */
|
|
49
77
|
function unseenTooLong(record) {
|
|
50
78
|
return (Date.now() - Number(record.submittedAt ?? record.firstSeenAt) >=
|
|
@@ -92,8 +120,12 @@ function unchecked(record) {
|
|
|
92
120
|
}
|
|
93
121
|
/** About an hour of polls; a later state() looks again. */
|
|
94
122
|
const SETTLE_POLL_ATTEMPTS = 240;
|
|
95
|
-
function clear(settled) {
|
|
96
|
-
return
|
|
123
|
+
function clear(settled, reservation) {
|
|
124
|
+
return {
|
|
125
|
+
status: "clear",
|
|
126
|
+
...(settled ? { settled } : {}),
|
|
127
|
+
...(reservation === undefined ? {} : { reservation }),
|
|
128
|
+
};
|
|
97
129
|
}
|
|
98
130
|
function ranExecuted(record, executionHash = record.executionHash, result) {
|
|
99
131
|
return {
|
|
@@ -157,12 +189,47 @@ function classify(record) {
|
|
|
157
189
|
*/
|
|
158
190
|
export function createWriteHold(options = {}) {
|
|
159
191
|
const storage = options.storage ?? createHeldWriteStorage();
|
|
192
|
+
/**
|
|
193
|
+
* Safe's service, every request and its body bounded together to
|
|
194
|
+
* {@link SAFE_SERVICE_READ_MS} whatever its fetch does with the signal: a read that times out is
|
|
195
|
+
* unknown, which never releases or settles, so a hung service can't hold
|
|
196
|
+
* a send or the call's lock.
|
|
197
|
+
*/
|
|
198
|
+
const service = {
|
|
199
|
+
...options.safeService,
|
|
200
|
+
fetch: (url, init) => {
|
|
201
|
+
const request = options.safeService?.fetch ?? globalThis.fetch;
|
|
202
|
+
let timer;
|
|
203
|
+
return Promise.race([
|
|
204
|
+
// The whole read, its body included, under the one deadline.
|
|
205
|
+
request(url, init).then(async (response) => new Response(await response.arrayBuffer(), {
|
|
206
|
+
status: response.status,
|
|
207
|
+
statusText: response.statusText,
|
|
208
|
+
headers: response.headers,
|
|
209
|
+
})),
|
|
210
|
+
new Promise((_, reject) => {
|
|
211
|
+
timer = setTimeout(() => reject(new Error("Safe's service did not answer in time.")), SAFE_SERVICE_READ_MS);
|
|
212
|
+
}),
|
|
213
|
+
]).finally(() => clearTimeout(timer));
|
|
214
|
+
},
|
|
215
|
+
};
|
|
160
216
|
const interval = options.settle?.intervalMs ?? SETTLE_POLL_MS;
|
|
161
217
|
const attempts = options.settle?.attempts ?? SETTLE_POLL_ATTEMPTS;
|
|
162
218
|
const listeners = new Map();
|
|
163
219
|
const peekers = new Map();
|
|
164
220
|
const timers = new Map();
|
|
165
221
|
let onStorage;
|
|
222
|
+
/**
|
|
223
|
+
* The one decorator every state leaves through for its storage notice:
|
|
224
|
+
* while holds last in this tab alone, a held state carries
|
|
225
|
+
* {@link WRITE_HOLD_IN_TAB_LINE}; otherwise none.
|
|
226
|
+
*/
|
|
227
|
+
function noticed(state) {
|
|
228
|
+
const { notice: _, ...rest } = state;
|
|
229
|
+
return state.status !== "clear" && !storage.durable()
|
|
230
|
+
? { ...rest, notice: WRITE_HOLD_IN_TAB_LINE }
|
|
231
|
+
: rest;
|
|
232
|
+
}
|
|
166
233
|
/** The latest state emitted for each call, replayed to a late subscriber. */
|
|
167
234
|
const latest = new Map();
|
|
168
235
|
function emit(key, state) {
|
|
@@ -175,7 +242,9 @@ export function createWriteHold(options = {}) {
|
|
|
175
242
|
if (onStorage || typeof globalThis.addEventListener !== "function")
|
|
176
243
|
return;
|
|
177
244
|
onStorage = (event) => {
|
|
178
|
-
if (event.key &&
|
|
245
|
+
if (event.key &&
|
|
246
|
+
(!event.key.startsWith("nana-sdk:reviewed-write") ||
|
|
247
|
+
event.key === "nana-sdk:reviewed-write:probe"))
|
|
179
248
|
return;
|
|
180
249
|
for (const [key, peek] of peekers) {
|
|
181
250
|
try {
|
|
@@ -187,11 +256,25 @@ export function createWriteHold(options = {}) {
|
|
|
187
256
|
}
|
|
188
257
|
};
|
|
189
258
|
globalThis.addEventListener("storage", onStorage);
|
|
259
|
+
liveHolds.add(dispose);
|
|
190
260
|
}
|
|
191
|
-
|
|
261
|
+
/** Stop every settle poll and the tab listener. */
|
|
262
|
+
function dispose() {
|
|
263
|
+
for (const timer of timers.values())
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
timers.clear();
|
|
266
|
+
if (onStorage)
|
|
267
|
+
globalThis.removeEventListener?.("storage", onStorage);
|
|
268
|
+
onStorage = undefined;
|
|
269
|
+
liveHolds.delete(dispose);
|
|
270
|
+
}
|
|
271
|
+
function openOne(call, hooks = {},
|
|
272
|
+
/** The key a found write was saved under, when it isn't its call's own. */
|
|
273
|
+
at) {
|
|
192
274
|
const recovery = createBrowserWriteRecovery(call, {
|
|
193
275
|
storage,
|
|
194
276
|
locks: options.locks,
|
|
277
|
+
at,
|
|
195
278
|
});
|
|
196
279
|
// The canonical recovery scope: a namespace and an exact call never share it.
|
|
197
280
|
const key = recovery.scopeKey;
|
|
@@ -199,11 +282,26 @@ export function createWriteHold(options = {}) {
|
|
|
199
282
|
let lastEffect;
|
|
200
283
|
/** Nothing held: how the call's last write settled, if saved. */
|
|
201
284
|
const cleared = () => clear(recovery.settled());
|
|
285
|
+
/** Every state leaves with the reservation it belongs to. */
|
|
286
|
+
const tagged = (state) => {
|
|
287
|
+
const reservation = state.reservation ??
|
|
288
|
+
(state.status === "clear"
|
|
289
|
+
? recovery.latestReservation()
|
|
290
|
+
: state.record.id);
|
|
291
|
+
return noticed({
|
|
292
|
+
...state,
|
|
293
|
+
...(reservation === undefined ? {} : { reservation }),
|
|
294
|
+
});
|
|
295
|
+
};
|
|
202
296
|
const peek = () => {
|
|
203
297
|
const record = recovery.read();
|
|
204
|
-
return record ? ungated(classify(record)) : cleared();
|
|
298
|
+
return tagged(record ? ungated(classify(record)) : cleared());
|
|
299
|
+
};
|
|
300
|
+
const shown = (state) => {
|
|
301
|
+
const out = tagged(state);
|
|
302
|
+
emit(key, out);
|
|
303
|
+
return out;
|
|
205
304
|
};
|
|
206
|
-
const shown = (state) => (emit(key, state), state);
|
|
207
305
|
async function locked(work, wait) {
|
|
208
306
|
let entered = false;
|
|
209
307
|
try {
|
|
@@ -233,6 +331,7 @@ export function createWriteHold(options = {}) {
|
|
|
233
331
|
function follow(client, id, left = attempts) {
|
|
234
332
|
if (timers.has(id) || left <= 0)
|
|
235
333
|
return;
|
|
334
|
+
liveHolds.add(dispose);
|
|
236
335
|
timers.set(id, setTimeout(() => {
|
|
237
336
|
void state(client)
|
|
238
337
|
.catch(() => null)
|
|
@@ -252,7 +351,7 @@ export function createWriteHold(options = {}) {
|
|
|
252
351
|
};
|
|
253
352
|
// Kept for any later reader: another tab, a reload, a follow timer.
|
|
254
353
|
recovery.markSettled(record, settled);
|
|
255
|
-
return clear(settled);
|
|
354
|
+
return clear(settled, record.id);
|
|
256
355
|
}
|
|
257
356
|
if (!record.safe &&
|
|
258
357
|
receipt.status === "reverted" &&
|
|
@@ -274,10 +373,37 @@ export function createWriteHold(options = {}) {
|
|
|
274
373
|
follow(client, record.id);
|
|
275
374
|
return classify(recovery.read());
|
|
276
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Whether a previous deploy's `hash` may be this call's: a valid hash
|
|
378
|
+
* read on the call's chain that isn't shown to be another write. A
|
|
379
|
+
* transaction must be the exact call from the account; a Safe proposal,
|
|
380
|
+
* exactly this call. One that can't be read is unknown, not refused.
|
|
381
|
+
*/
|
|
382
|
+
async function adoptable(hash, client) {
|
|
383
|
+
if (!isBytes32(hash) ||
|
|
384
|
+
!client ||
|
|
385
|
+
(await client.getChainId().catch(() => null)) !== call.chainId)
|
|
386
|
+
return false;
|
|
387
|
+
const reviewed = {
|
|
388
|
+
account: call.account.toLowerCase(),
|
|
389
|
+
chainId: call.chainId,
|
|
390
|
+
call: {
|
|
391
|
+
to: call.call.to.toLowerCase(),
|
|
392
|
+
data: call.call.data.toLowerCase(),
|
|
393
|
+
value: String(call.call.value ?? 0),
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
if (call.safe) {
|
|
397
|
+
const proposal = await readSafeTransaction(call.chainId, call.account, hash, service).catch(() => null);
|
|
398
|
+
return !proposal || safeProposalIsHeldCall(proposal, call);
|
|
399
|
+
}
|
|
400
|
+
const sent = await client.getTransaction({ hash }).catch(() => null);
|
|
401
|
+
return !sent || sentIsCall(sent, hash, reviewed);
|
|
402
|
+
}
|
|
277
403
|
/** The authenticated nonce of a Safe proposal, from Safe's service. */
|
|
278
404
|
async function proposalNonceOf(record) {
|
|
279
405
|
try {
|
|
280
|
-
const proposal = await readSafeTransaction(record.chainId, record.account, record.hash,
|
|
406
|
+
const proposal = await readSafeTransaction(record.chainId, record.account, record.hash, service);
|
|
281
407
|
return safeTransactionMessage(proposal).nonce;
|
|
282
408
|
}
|
|
283
409
|
catch {
|
|
@@ -344,8 +470,22 @@ export function createWriteHold(options = {}) {
|
|
|
344
470
|
// Only positive pending evidence stops the clock; a read that fails
|
|
345
471
|
// keeps the one saved.
|
|
346
472
|
if (look === "pending")
|
|
347
|
-
return {
|
|
473
|
+
return {
|
|
474
|
+
...state,
|
|
475
|
+
record: recovery.seenPending(recovery.mined(state.record, false)),
|
|
476
|
+
};
|
|
348
477
|
const record = look === "mined" ? recovery.mined(state.record, true) : state.record;
|
|
478
|
+
// Unknown or not found is not pending: with no read showing it pending
|
|
479
|
+
// or mined for 30 minutes (since it was last seen pending, or else
|
|
480
|
+
// submitted), the warned explicit exit, never a release on its own. A
|
|
481
|
+
// Safe proposal's hash isn't a transaction to read.
|
|
482
|
+
if ((look === "unknown" || look === null) &&
|
|
483
|
+
!record.safe &&
|
|
484
|
+
record.minedAt === undefined &&
|
|
485
|
+
Date.now() -
|
|
486
|
+
Number(record.pendingSeenAt ?? record.submittedAt ?? record.firstSeenAt) >=
|
|
487
|
+
UNSEEN_HASH_MS)
|
|
488
|
+
return unchecked(record);
|
|
349
489
|
if (record.minedAt === undefined)
|
|
350
490
|
return state;
|
|
351
491
|
if (Date.now() - Number(record.minedAt) >= UNSEEN_HASH_MS)
|
|
@@ -354,7 +494,9 @@ export function createWriteHold(options = {}) {
|
|
|
354
494
|
}
|
|
355
495
|
/** What the node shows of `hash`: positive pending or mined metadata, or nothing it can say. */
|
|
356
496
|
async function lookAt(hash, client) {
|
|
357
|
-
const sent = await client.getTransaction({ hash }).
|
|
497
|
+
const sent = await client.getTransaction({ hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
|
|
498
|
+
if (sent === undefined)
|
|
499
|
+
return "unknown";
|
|
358
500
|
if (sent?.hash?.toLowerCase() !== hash)
|
|
359
501
|
return null;
|
|
360
502
|
return sent.blockNumber === null ? "pending" : "mined";
|
|
@@ -375,7 +517,8 @@ export function createWriteHold(options = {}) {
|
|
|
375
517
|
for (const hash of seen) {
|
|
376
518
|
const own = hash === record.hash;
|
|
377
519
|
const look = await lookAt(hash, client);
|
|
378
|
-
|
|
520
|
+
// Unknown changes nothing: only positive metadata moves a clock.
|
|
521
|
+
if (look === "pending" || look === "mined")
|
|
379
522
|
record = own
|
|
380
523
|
? recovery.mined(record, look === "mined")
|
|
381
524
|
: recovery.candidateMined(record, hash, look === "mined");
|
|
@@ -465,15 +608,15 @@ export function createWriteHold(options = {}) {
|
|
|
465
608
|
}
|
|
466
609
|
const end = (record.safeNonce !== undefined &&
|
|
467
610
|
(await provenSafeProposalEnd(client, record.account, hash, BigInt(record.safeNonce)))) ||
|
|
468
|
-
(await lookAtSafeProposalEnd(client, record.chainId, record.account, hash,
|
|
611
|
+
(await lookAtSafeProposalEnd(client, record.chainId, record.account, hash, service));
|
|
469
612
|
// Another Safe transaction took its nonce, even after a saved execution
|
|
470
613
|
// was reorganized away. It may have run the call too: the exit, never a release.
|
|
471
614
|
if (end.look === "replaced")
|
|
472
615
|
return safeReplaced(recovery.replacedOnChain(record));
|
|
473
616
|
if (end.look === "expired" && !record.executed) {
|
|
474
|
-
const expired = await verifyReviewedWriteExpiry(client, record,
|
|
617
|
+
const expired = await verifyReviewedWriteExpiry(client, record, service).catch(() => false);
|
|
475
618
|
if (expired && release(record))
|
|
476
|
-
return clear({ result: "expired" });
|
|
619
|
+
return clear({ result: "expired" }, record.id);
|
|
477
620
|
}
|
|
478
621
|
if (end.look === "executed") {
|
|
479
622
|
if (!record.executed ||
|
|
@@ -495,7 +638,7 @@ export function createWriteHold(options = {}) {
|
|
|
495
638
|
hash: end.execution.executionHash,
|
|
496
639
|
};
|
|
497
640
|
recovery.markSettled(record, settled);
|
|
498
|
-
return clear(settled);
|
|
641
|
+
return clear(settled, record.id);
|
|
499
642
|
}
|
|
500
643
|
}
|
|
501
644
|
if (!record.executed &&
|
|
@@ -536,6 +679,9 @@ export function createWriteHold(options = {}) {
|
|
|
536
679
|
let record = recovery.read();
|
|
537
680
|
if (!record)
|
|
538
681
|
return cleared();
|
|
682
|
+
// A mirror carries no evidence of its own: its source attempt does.
|
|
683
|
+
if (record.mirrorOf)
|
|
684
|
+
return classify(record);
|
|
539
685
|
// No evidence from another chain.
|
|
540
686
|
if ((await client.getChainId().catch(() => null)) !== record.chainId)
|
|
541
687
|
return classify(record);
|
|
@@ -599,9 +745,7 @@ export function createWriteHold(options = {}) {
|
|
|
599
745
|
}
|
|
600
746
|
if (record.hash &&
|
|
601
747
|
record.sentNonce === undefined &&
|
|
602
|
-
(record
|
|
603
|
-
record.fromBlock === undefined ||
|
|
604
|
-
unseenTooLong(record)) &&
|
|
748
|
+
unseenTooLong(record) &&
|
|
605
749
|
(await client.getTransaction({ hash: record.hash }).then(() => false, (error) => error?.name === "TransactionNotFoundError")))
|
|
606
750
|
return unchecked(record);
|
|
607
751
|
return classify(record);
|
|
@@ -613,7 +757,7 @@ export function createWriteHold(options = {}) {
|
|
|
613
757
|
async function state(client, checks = {}) {
|
|
614
758
|
lastEffect = checks.effect;
|
|
615
759
|
const result = await locked(() => inspect(client, checks.effect), false);
|
|
616
|
-
return shown(result ? result.value : peek());
|
|
760
|
+
return { ...shown(result ? result.value : peek()), checked: !!result };
|
|
617
761
|
}
|
|
618
762
|
return {
|
|
619
763
|
state,
|
|
@@ -637,8 +781,11 @@ export function createWriteHold(options = {}) {
|
|
|
637
781
|
if (last) {
|
|
638
782
|
const now = peek();
|
|
639
783
|
// A clear is compared by its settlement's identity, a hold by its record.
|
|
640
|
-
const identity = (state) => JSON.stringify(
|
|
641
|
-
|
|
784
|
+
const identity = (state) => JSON.stringify([
|
|
785
|
+
state.reservation ?? null,
|
|
786
|
+
state.status === "clear" ? (state.settled ?? null) : state.record,
|
|
787
|
+
]);
|
|
788
|
+
listener(noticed(ungated(identity(now) === identity(last) ? last : now)));
|
|
642
789
|
}
|
|
643
790
|
return () => {
|
|
644
791
|
set.delete(listener);
|
|
@@ -657,6 +804,10 @@ export function createWriteHold(options = {}) {
|
|
|
657
804
|
*/
|
|
658
805
|
async send(client, run) {
|
|
659
806
|
let attempt;
|
|
807
|
+
let ended;
|
|
808
|
+
const until = new Promise((resolve) => (ended = resolve));
|
|
809
|
+
let released;
|
|
810
|
+
let reviewed = "";
|
|
660
811
|
const gate = {
|
|
661
812
|
async beforeSend(actualCall) {
|
|
662
813
|
let evidence;
|
|
@@ -670,12 +821,31 @@ export function createWriteHold(options = {}) {
|
|
|
670
821
|
throw new WriteHoldNonceError(cause);
|
|
671
822
|
}
|
|
672
823
|
}
|
|
673
|
-
|
|
824
|
+
const reserve = () => {
|
|
674
825
|
attempt = recovery.reserve(actualCall, evidence);
|
|
826
|
+
};
|
|
827
|
+
try {
|
|
828
|
+
if (hooks.across)
|
|
829
|
+
({ released } = await hooks.across((work) => recovery.withLock(work), reserve, () => {
|
|
830
|
+
recovery.rejected();
|
|
831
|
+
attempt = undefined;
|
|
832
|
+
}, until, reviewed));
|
|
833
|
+
else
|
|
834
|
+
reserve();
|
|
675
835
|
}
|
|
676
836
|
catch (error) {
|
|
677
|
-
|
|
678
|
-
|
|
837
|
+
if (error instanceof WriteHeldError ||
|
|
838
|
+
error instanceof WriteHoldBusyError ||
|
|
839
|
+
error instanceof WriteHoldStaleReviewError)
|
|
840
|
+
throw error;
|
|
841
|
+
if (hooks.across) {
|
|
842
|
+
// No lock is held here: what the saved record says.
|
|
843
|
+
const now = shown(peek());
|
|
844
|
+
if (now.status !== "clear")
|
|
845
|
+
throw new WriteHeldError(now);
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
const now = shown(await finish(classify(recovery.read()), client));
|
|
679
849
|
if (now.status !== "clear")
|
|
680
850
|
throw new WriteHeldError(now);
|
|
681
851
|
throw error;
|
|
@@ -710,6 +880,25 @@ export function createWriteHold(options = {}) {
|
|
|
710
880
|
shown(peek());
|
|
711
881
|
},
|
|
712
882
|
};
|
|
883
|
+
if (hooks.across) {
|
|
884
|
+
// Checked under this key's lock, which is then let go: the final
|
|
885
|
+
// check and reservation take every lock again, in order.
|
|
886
|
+
const checked = await locked(async () => shown(await inspect(client)), false);
|
|
887
|
+
if (!checked)
|
|
888
|
+
throw new WriteHoldBusyError();
|
|
889
|
+
if (checked.value.status !== "clear")
|
|
890
|
+
throw new WriteHeldError(checked.value);
|
|
891
|
+
// Given with `across`: the generations the review is checked against.
|
|
892
|
+
reviewed = hooks.generations();
|
|
893
|
+
try {
|
|
894
|
+
return await run(gate);
|
|
895
|
+
}
|
|
896
|
+
finally {
|
|
897
|
+
// The send ends: every lock it reserved under is let go first.
|
|
898
|
+
ended();
|
|
899
|
+
await released;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
713
902
|
const result = await locked(async () => {
|
|
714
903
|
const now = shown(await inspect(client));
|
|
715
904
|
if (now.status !== "clear")
|
|
@@ -746,11 +935,18 @@ export function createWriteHold(options = {}) {
|
|
|
746
935
|
state: await finish(classify(recovery.read()), client),
|
|
747
936
|
};
|
|
748
937
|
}
|
|
749
|
-
return {
|
|
750
|
-
|
|
938
|
+
return {
|
|
939
|
+
released: true,
|
|
940
|
+
state: clear(undefined, snapshot.record.id),
|
|
941
|
+
};
|
|
942
|
+
// Never waits behind another send's wallet prompt: on contention,
|
|
943
|
+
// what the saved record says, with no exit.
|
|
944
|
+
}, false);
|
|
751
945
|
const ended = result?.value ?? { released: false, state: peek() };
|
|
752
|
-
|
|
753
|
-
|
|
946
|
+
return {
|
|
947
|
+
...ended,
|
|
948
|
+
state: { ...shown(ended.state), checked: !!result },
|
|
949
|
+
};
|
|
754
950
|
},
|
|
755
951
|
/**
|
|
756
952
|
* A receipt for the held write: an ordinary write's own hash, or a Safe
|
|
@@ -761,6 +957,7 @@ export function createWriteHold(options = {}) {
|
|
|
761
957
|
const result = await locked(async () => {
|
|
762
958
|
const record = recovery.read();
|
|
763
959
|
if (!record ||
|
|
960
|
+
record.mirrorOf ||
|
|
764
961
|
(!record.safe &&
|
|
765
962
|
record.hash !== receipt?.transactionHash?.toLowerCase()))
|
|
766
963
|
return finish(classify(record), client);
|
|
@@ -793,23 +990,108 @@ export function createWriteHold(options = {}) {
|
|
|
793
990
|
* one lock, without releasing it. The hold then follows that hash: no
|
|
794
991
|
* exit while it is pending, and its receipt settles it.
|
|
795
992
|
*/
|
|
993
|
+
/**
|
|
994
|
+
* Move a write a previous deploy kept (outside these holds) into this
|
|
995
|
+
* hold. `snapshot` is the state the app read before deciding. Under
|
|
996
|
+
* the call's lock, it reserves a held record with `evidence` (the
|
|
997
|
+
* account's next nonce, or the nonce it was sent at, with the block it
|
|
998
|
+
* was read at; when it was sent; and its `hash`, or for a Safe write
|
|
999
|
+
* `safe.safeTxHash`) only while nothing is held and no reservation
|
|
1000
|
+
* came since `snapshot`: another tab's hold or settlement wins, and
|
|
1001
|
+
* the current state is returned unchanged. A hash is checked first
|
|
1002
|
+
* with `client`: a transaction that is another call, from another
|
|
1003
|
+
* account or on another chain (or a Safe proposal of another call) is
|
|
1004
|
+
* refused, adopting nothing. One the chain can't show is held, with
|
|
1005
|
+
* the warned exit 30 minutes after it was sent (or adopted). Then the
|
|
1006
|
+
* gate reads it: a mined one settles on its verified receipt. It never
|
|
1007
|
+
* sends or releases; adopting twice holds once.
|
|
1008
|
+
*/
|
|
1009
|
+
async adopt(snapshot, evidence = {}, client) {
|
|
1010
|
+
const nonce = evidence.nonce ?? evidence.sentNonce;
|
|
1011
|
+
const hash = (call.safe ? evidence.safe?.safeTxHash : evidence.hash)?.toLowerCase();
|
|
1012
|
+
/** Nothing held, and no reservation since the app's read. */
|
|
1013
|
+
const free = () => snapshot.status === "clear" &&
|
|
1014
|
+
!recovery.read() &&
|
|
1015
|
+
recovery.latestReservation() === snapshot.reservation;
|
|
1016
|
+
const reserve = () => recovery.reserve(undefined, nonce !== undefined && evidence.fromBlock !== undefined
|
|
1017
|
+
? { nonce, blockNumber: evidence.fromBlock }
|
|
1018
|
+
: undefined, {
|
|
1019
|
+
hash,
|
|
1020
|
+
submittedAt: evidence.submittedAt === undefined
|
|
1021
|
+
? undefined
|
|
1022
|
+
: String(evidence.submittedAt),
|
|
1023
|
+
});
|
|
1024
|
+
if (hooks.across) {
|
|
1025
|
+
// Under an intent: the same atomic reserve as a send, under every
|
|
1026
|
+
// legacy key's lock, mirrored at each so older versions see it.
|
|
1027
|
+
if (!free() ||
|
|
1028
|
+
(hash !== undefined && !(await adoptable(hash, client))))
|
|
1029
|
+
return shown(peek());
|
|
1030
|
+
let adopted = false;
|
|
1031
|
+
try {
|
|
1032
|
+
const { released } = await hooks.across((work) => recovery.withLock(work), () => {
|
|
1033
|
+
if (!free())
|
|
1034
|
+
throw new Error("Held since the app's read.");
|
|
1035
|
+
reserve();
|
|
1036
|
+
adopted = true;
|
|
1037
|
+
}, recovery.rejected, Promise.resolve(), hooks.generations());
|
|
1038
|
+
await released;
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
/* Held elsewhere, busy, or no longer free: nothing adopted. */
|
|
1042
|
+
}
|
|
1043
|
+
// A mined hash settles on its verified receipt, through the gate.
|
|
1044
|
+
return adopted && hash && client ? state(client) : shown(peek());
|
|
1045
|
+
}
|
|
1046
|
+
const result = await locked(async () => {
|
|
1047
|
+
if (!free())
|
|
1048
|
+
return peek();
|
|
1049
|
+
if (hash !== undefined && !(await adoptable(hash, client)))
|
|
1050
|
+
return peek();
|
|
1051
|
+
reserve();
|
|
1052
|
+
return hash && client ? inspect(client) : peek();
|
|
1053
|
+
}, true);
|
|
1054
|
+
return shown(result?.value ?? peek());
|
|
1055
|
+
},
|
|
1056
|
+
/**
|
|
1057
|
+
* Save the Safe nonce of a held Safe proposal adopted before it was
|
|
1058
|
+
* known, so a replacement can be proven later. Under the call's lock,
|
|
1059
|
+
* only while the held proposal is `safeTxHash` with no nonce yet, and
|
|
1060
|
+
* only when the call's proposal at `nonce` hashes to it: the hash is
|
|
1061
|
+
* the proof. Then the gate reads it. Never sends or releases.
|
|
1062
|
+
*/
|
|
1063
|
+
async attachSafeNonce(client, { safeTxHash, nonce }) {
|
|
1064
|
+
const result = await locked(async () => {
|
|
1065
|
+
const record = recovery.read();
|
|
1066
|
+
if (record?.safe &&
|
|
1067
|
+
!record.mirrorOf &&
|
|
1068
|
+
record.safeNonce === undefined &&
|
|
1069
|
+
record.hash === safeTxHash.toLowerCase())
|
|
1070
|
+
try {
|
|
1071
|
+
canonicalSafeTxHash(call.chainId, call.account, heldSafeProposal(call, nonce), safeTxHash);
|
|
1072
|
+
recovery.proposalNonce(record, BigInt(nonce));
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
/* Not this proposal at that nonce: nothing saved. */
|
|
1076
|
+
}
|
|
1077
|
+
return inspect(client);
|
|
1078
|
+
}, true);
|
|
1079
|
+
return shown(result?.value ?? peek());
|
|
1080
|
+
},
|
|
796
1081
|
async attachHash(client, hash) {
|
|
797
1082
|
const result = await locked(async () => {
|
|
798
1083
|
const record = recovery.read();
|
|
799
1084
|
if (record &&
|
|
800
1085
|
!record.safe &&
|
|
801
1086
|
!record.hash &&
|
|
1087
|
+
!record.mirrorOf &&
|
|
802
1088
|
isBytes32(hash) &&
|
|
803
1089
|
(await client.getChainId().catch(() => null)) === record.chainId) {
|
|
804
1090
|
const sent = await client
|
|
805
1091
|
.getTransaction({ hash })
|
|
806
1092
|
.catch(() => null);
|
|
807
|
-
if (sent
|
|
808
|
-
sent
|
|
809
|
-
(sent.chainId === undefined || sent.chainId === record.chainId) &&
|
|
810
|
-
sent.to?.toLowerCase() === record.call.to &&
|
|
811
|
-
sent.input?.toLowerCase() === record.call.data &&
|
|
812
|
-
BigInt(sent.value) === BigInt(record.call.value) &&
|
|
1093
|
+
if (sent &&
|
|
1094
|
+
sentIsCall(sent, hash, record) &&
|
|
813
1095
|
// This attempt, not an earlier identical call: never below the
|
|
814
1096
|
// account's next nonce or mined before it was reserved.
|
|
815
1097
|
// With no such evidence, an identical earlier call can't be
|
|
@@ -862,6 +1144,338 @@ export function createWriteHold(options = {}) {
|
|
|
862
1144
|
},
|
|
863
1145
|
};
|
|
864
1146
|
}
|
|
1147
|
+
/**
|
|
1148
|
+
* `legacy`: for a write held under an intent namespace, the keys an earlier
|
|
1149
|
+
* version held it under (an old namespace, or `{ call }` for an exact
|
|
1150
|
+
* call); the current exact call is always checked. `{ match: { to,
|
|
1151
|
+
* selector } }` finds them instead: every exact-call write this account
|
|
1152
|
+
* saved on this chain to `to` with that 4-byte selector, for a hold saved
|
|
1153
|
+
* before the intent key existed under calldata the retry can't rebuild (an
|
|
1154
|
+
* old minimum, expiration or deadline). Pass one only for a write whose
|
|
1155
|
+
* calldata is volatile: it holds every such call. It is found when the
|
|
1156
|
+
* handle opens, and again right before a send reserves. While any of them holds
|
|
1157
|
+
* an unresolved write, the intent handle *is* that hold: its state, its
|
|
1158
|
+
* refusal and its own exit, in place. Each is checked alone, under its own
|
|
1159
|
+
* lock only, never two at once. Only when all are clear does the intent's
|
|
1160
|
+
* own record apply, and only then can a send reserve; they are checked
|
|
1161
|
+
* again right before it does.
|
|
1162
|
+
*/
|
|
1163
|
+
function open(call, { legacy = [], } = {}) {
|
|
1164
|
+
if (call.namespace === undefined)
|
|
1165
|
+
return openExact(call);
|
|
1166
|
+
const matches = legacy.flatMap((source) => typeof source === "object" && "match" in source ? [source.match] : []);
|
|
1167
|
+
/** Every exact-call write the matches find in storage now. */
|
|
1168
|
+
const matched = () => matches.flatMap((match) => findReviewedWrites({ chainId: call.chainId, account: call.account, ...match }, { storage }));
|
|
1169
|
+
const namespace = call.namespace;
|
|
1170
|
+
const own = openOne(call, {
|
|
1171
|
+
across: reserveAcross,
|
|
1172
|
+
generations: () => generations(),
|
|
1173
|
+
});
|
|
1174
|
+
const ownJournal = createBrowserWriteRecovery(call, { storage });
|
|
1175
|
+
/** Every involved key's latest reservation id, its generation. */
|
|
1176
|
+
const generations = () => JSON.stringify([ownJournal, ...journals].map((journal) => journal.latestReservation()));
|
|
1177
|
+
const { namespace: _, ...exact } = call;
|
|
1178
|
+
const scopeOf = (input) => createBrowserWriteRecovery(input, { storage }).scopeKey;
|
|
1179
|
+
const scopes = new Set([scopeOf(call)]);
|
|
1180
|
+
// Mirror-aware: evidence and exits always act on the source attempt.
|
|
1181
|
+
const delegates = [];
|
|
1182
|
+
const journals = [];
|
|
1183
|
+
/** Hold `inputs` too, each once, in one canonical order by scope for every writer's locks. */
|
|
1184
|
+
function include(inputs) {
|
|
1185
|
+
const added = inputs.filter(({ input, at }) => {
|
|
1186
|
+
const scope = at ?? scopeOf(input);
|
|
1187
|
+
return !scopes.has(scope) && !!scopes.add(scope);
|
|
1188
|
+
});
|
|
1189
|
+
if (!added.length)
|
|
1190
|
+
return;
|
|
1191
|
+
const keyed = [
|
|
1192
|
+
...delegates.map((delegate, index) => ({
|
|
1193
|
+
delegate,
|
|
1194
|
+
journal: journals[index],
|
|
1195
|
+
})),
|
|
1196
|
+
...added.map(({ input, at }) => ({
|
|
1197
|
+
delegate: openExact(input, at),
|
|
1198
|
+
journal: createBrowserWriteRecovery(input, {
|
|
1199
|
+
storage,
|
|
1200
|
+
locks: options.locks,
|
|
1201
|
+
at,
|
|
1202
|
+
}),
|
|
1203
|
+
})),
|
|
1204
|
+
].sort((a, b) => Number(a.journal.scopeKey > b.journal.scopeKey) -
|
|
1205
|
+
Number(a.journal.scopeKey < b.journal.scopeKey));
|
|
1206
|
+
delegates.splice(0, delegates.length, ...keyed.map((k) => k.delegate));
|
|
1207
|
+
journals.splice(0, journals.length, ...keyed.map((k) => k.journal));
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Look for matching writes again (on every state(), send() and exit());
|
|
1211
|
+
* those found since are held too, each at the exact key it was found
|
|
1212
|
+
* under, with its own Safe flag. Returns the ones that are new.
|
|
1213
|
+
*/
|
|
1214
|
+
function rescan() {
|
|
1215
|
+
const found = matched().filter(({ key }) => !scopes.has(key));
|
|
1216
|
+
include(found.map(({ key, record }) => ({
|
|
1217
|
+
input: { ...exact, safe: record.safe, call: record.call },
|
|
1218
|
+
at: key,
|
|
1219
|
+
})));
|
|
1220
|
+
return found.map(({ record }) => record);
|
|
1221
|
+
}
|
|
1222
|
+
include([{ call: call.call }, ...legacy].flatMap((source) => typeof source === "string"
|
|
1223
|
+
? [{ input: { ...call, namespace: source } }]
|
|
1224
|
+
: "call" in source
|
|
1225
|
+
? [{ input: { ...exact, call: source.call } }]
|
|
1226
|
+
: []));
|
|
1227
|
+
rescan();
|
|
1228
|
+
/** A legacy key holding anything but this intent's own mirror. */
|
|
1229
|
+
const foreign = (index) => journals[index].read()?.mirrorOf !== namespace &&
|
|
1230
|
+
delegates[index].peek().status !== "clear";
|
|
1231
|
+
/** The legacy hold the intent stands for right now, from storage alone. */
|
|
1232
|
+
const active = () => delegates.find((_, index) => foreign(index));
|
|
1233
|
+
/** The first legacy key holding an unresolved write, through its own gate. */
|
|
1234
|
+
async function delegated(client) {
|
|
1235
|
+
rescan();
|
|
1236
|
+
for (const [index, handle] of delegates.entries()) {
|
|
1237
|
+
if (!foreign(index))
|
|
1238
|
+
continue;
|
|
1239
|
+
const state = await handle.state(client);
|
|
1240
|
+
if (state.status !== "clear")
|
|
1241
|
+
return state;
|
|
1242
|
+
}
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* The final check and reservation, under every legacy key's lock taken
|
|
1247
|
+
* in one sorted order (the intent's own is held by its send), each
|
|
1248
|
+
* without waiting: a key another writer holds refuses as busy. A legacy
|
|
1249
|
+
* hold refuses; otherwise the intent reserves, and mirrors itself at each
|
|
1250
|
+
* legacy key so its readers, older versions included, see it held.
|
|
1251
|
+
*/
|
|
1252
|
+
async function reserveAcross(ownLock, reserve, unreserve, until, reviewed) {
|
|
1253
|
+
const locks = [
|
|
1254
|
+
{ scope: scopeOf(call), take: ownLock },
|
|
1255
|
+
...journals.map((journal) => ({
|
|
1256
|
+
scope: journal.scopeKey,
|
|
1257
|
+
take: (work) => journal.withLock(work),
|
|
1258
|
+
})),
|
|
1259
|
+
].sort((a, b) => Number(a.scope > b.scope) - Number(a.scope < b.scope));
|
|
1260
|
+
const deadline = Date.now() + RESERVE_RETRY_MS;
|
|
1261
|
+
for (;;) {
|
|
1262
|
+
let entered = 0;
|
|
1263
|
+
let reserved;
|
|
1264
|
+
const done = new Promise((resolve) => (reserved = resolve));
|
|
1265
|
+
const acquire = async (index) => {
|
|
1266
|
+
if (index < locks.length)
|
|
1267
|
+
return locks[index].take(async () => {
|
|
1268
|
+
entered += 1;
|
|
1269
|
+
return acquire(index + 1);
|
|
1270
|
+
});
|
|
1271
|
+
const refuse = (held) => new WriteHeldError(noticed({
|
|
1272
|
+
...ungated(classify(held)),
|
|
1273
|
+
reservation: held.id,
|
|
1274
|
+
}));
|
|
1275
|
+
for (const journal of journals) {
|
|
1276
|
+
const held = journal.read();
|
|
1277
|
+
if (held && held.mirrorOf !== namespace)
|
|
1278
|
+
throw refuse(held);
|
|
1279
|
+
}
|
|
1280
|
+
// A matching write saved since the last look holds it too, and
|
|
1281
|
+
// its own state() and exit() are reached from now on.
|
|
1282
|
+
const [late] = rescan();
|
|
1283
|
+
if (late)
|
|
1284
|
+
throw refuse(late);
|
|
1285
|
+
// Another attempt since this review opened: review it again.
|
|
1286
|
+
if (generations() !== reviewed)
|
|
1287
|
+
throw new WriteHoldStaleReviewError(ownJournal.settled());
|
|
1288
|
+
reserve();
|
|
1289
|
+
const record = own.peek().record;
|
|
1290
|
+
// Every mirror is saved, in the store the hold itself uses (its
|
|
1291
|
+
// memory fallback included), before the wallet opens; otherwise
|
|
1292
|
+
// this unsent reservation and its partial mirrors are undone.
|
|
1293
|
+
const mirrors = [];
|
|
1294
|
+
try {
|
|
1295
|
+
for (const journal of journals)
|
|
1296
|
+
mirrors.push([journal, journal.mirror(record, namespace)]);
|
|
1297
|
+
}
|
|
1298
|
+
catch (error) {
|
|
1299
|
+
for (const [journal, mirror] of mirrors)
|
|
1300
|
+
try {
|
|
1301
|
+
journal.clear(mirror);
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
/* Held for old readers until their explicit exit. */
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
unreserve();
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
/* Unsent, it stays held with its explicit exit. */
|
|
1311
|
+
}
|
|
1312
|
+
throw error;
|
|
1313
|
+
}
|
|
1314
|
+
// Reserved: every lock is kept until the send ends.
|
|
1315
|
+
reserved();
|
|
1316
|
+
await until;
|
|
1317
|
+
};
|
|
1318
|
+
const holding = acquire(0);
|
|
1319
|
+
const failed = await Promise.race([
|
|
1320
|
+
done.then(() => null),
|
|
1321
|
+
holding.then(() => null, (error) => ({ error })),
|
|
1322
|
+
]);
|
|
1323
|
+
if (!failed)
|
|
1324
|
+
return { released: holding.catch(() => { }) };
|
|
1325
|
+
// Not reserved: every lock, its own included, is let go before any
|
|
1326
|
+
// retry; the checks run again each time.
|
|
1327
|
+
const { error } = failed;
|
|
1328
|
+
if (entered === locks.length ||
|
|
1329
|
+
error instanceof WriteHeldError ||
|
|
1330
|
+
error instanceof WriteHoldStaleReviewError)
|
|
1331
|
+
throw error;
|
|
1332
|
+
if (Date.now() >= deadline)
|
|
1333
|
+
throw new WriteHoldBusyError();
|
|
1334
|
+
await new Promise((resolve) => setTimeout(resolve, 25 + Math.random() * 75));
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
/** Remove this intent's mirrors of the reservation that ended, by its id only. */
|
|
1338
|
+
async function clearMirrors(ended) {
|
|
1339
|
+
for (const journal of journals) {
|
|
1340
|
+
if (journal.read()?.id !== ended)
|
|
1341
|
+
continue;
|
|
1342
|
+
await journal
|
|
1343
|
+
.withLock(async () => {
|
|
1344
|
+
const current = journal.read();
|
|
1345
|
+
if (current?.mirrorOf === namespace && current.id === ended)
|
|
1346
|
+
journal.clear(current);
|
|
1347
|
+
})
|
|
1348
|
+
.catch(() => {
|
|
1349
|
+
/* Kept for old readers until their explicit exit. */
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
/** After a call that ended this intent's reservation, its mirrors go. */
|
|
1354
|
+
const after = async (value) => {
|
|
1355
|
+
if (value.status === "clear" && value.reservation)
|
|
1356
|
+
await clearMirrors(value.reservation);
|
|
1357
|
+
return value;
|
|
1358
|
+
};
|
|
1359
|
+
return {
|
|
1360
|
+
...own,
|
|
1361
|
+
peek: () => active()?.peek() ?? own.peek(),
|
|
1362
|
+
async state(client, checks) {
|
|
1363
|
+
return ((await delegated(client)) ?? after(await own.state(client, checks)));
|
|
1364
|
+
},
|
|
1365
|
+
async send(client, run) {
|
|
1366
|
+
const held = await delegated(client);
|
|
1367
|
+
if (held)
|
|
1368
|
+
throw new WriteHeldError(held);
|
|
1369
|
+
return own.send(client, run);
|
|
1370
|
+
},
|
|
1371
|
+
async exit(snapshot, acknowledgement, client) {
|
|
1372
|
+
rescan();
|
|
1373
|
+
const owner = snapshot.status === "clear"
|
|
1374
|
+
? undefined
|
|
1375
|
+
: delegates.find((handle, index) => {
|
|
1376
|
+
const now = handle.peek();
|
|
1377
|
+
return (foreign(index) &&
|
|
1378
|
+
now.status !== "clear" &&
|
|
1379
|
+
now.record.id === snapshot.record.id);
|
|
1380
|
+
});
|
|
1381
|
+
if (owner)
|
|
1382
|
+
return owner.exit(snapshot, acknowledgement, client);
|
|
1383
|
+
const held = await delegated(client);
|
|
1384
|
+
if (held)
|
|
1385
|
+
return { released: false, state: held };
|
|
1386
|
+
const ended = await own.exit(snapshot, acknowledgement, client);
|
|
1387
|
+
await after(ended.state);
|
|
1388
|
+
return ended;
|
|
1389
|
+
},
|
|
1390
|
+
onReceipt: async (...args) => active()?.onReceipt(...args) ?? after(await own.onReceipt(...args)),
|
|
1391
|
+
onUnproven: async (...args) => active()?.onUnproven(...args) ?? after(await own.onUnproven(...args)),
|
|
1392
|
+
onExecuted: async (...args) => active()?.onExecuted(...args) ?? after(await own.onExecuted(...args)),
|
|
1393
|
+
attachHash: async (...args) => active()?.attachHash(...args) ?? after(await own.attachHash(...args)),
|
|
1394
|
+
attachSafeNonce: async (...args) => active()?.attachSafeNonce(...args) ??
|
|
1395
|
+
after(await own.attachSafeNonce(...args)),
|
|
1396
|
+
// A legacy hold already holds it: nothing is adopted.
|
|
1397
|
+
adopt: async (...args) => active()?.peek() ?? after(await own.adopt(...args)),
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* An exact-call handle. When its key holds a mirror of an intent's write,
|
|
1402
|
+
* it is that intent's hold: it never sends or releases on its own, and its
|
|
1403
|
+
* exit is the intent's. A mirror left after the intent ended is removed.
|
|
1404
|
+
*/
|
|
1405
|
+
function openExact(call, at) {
|
|
1406
|
+
const own = openOne(call, {}, at);
|
|
1407
|
+
const journal = createBrowserWriteRecovery(call, {
|
|
1408
|
+
storage,
|
|
1409
|
+
locks: options.locks,
|
|
1410
|
+
at,
|
|
1411
|
+
});
|
|
1412
|
+
/** The intent this key mirrors, from storage alone. */
|
|
1413
|
+
const mirrored = () => {
|
|
1414
|
+
const record = journal.read();
|
|
1415
|
+
return record?.mirrorOf
|
|
1416
|
+
? { record, intent: openOne({ ...call, namespace: record.mirrorOf }) }
|
|
1417
|
+
: undefined;
|
|
1418
|
+
};
|
|
1419
|
+
/** Remove a mirror whose intent holds nothing any more. */
|
|
1420
|
+
async function clearStale(found) {
|
|
1421
|
+
await journal
|
|
1422
|
+
.withLock(async () => {
|
|
1423
|
+
const current = journal.read();
|
|
1424
|
+
if (current?.id === found.record.id)
|
|
1425
|
+
journal.clear(current);
|
|
1426
|
+
})
|
|
1427
|
+
.catch(() => {
|
|
1428
|
+
/* The stale mirror still holds; its intent is clear. */
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
/** The intent's state while it holds anything: this key is its mirror. */
|
|
1432
|
+
async function intentState(client) {
|
|
1433
|
+
const found = mirrored();
|
|
1434
|
+
if (!found)
|
|
1435
|
+
return null;
|
|
1436
|
+
const state = await found.intent.state(client);
|
|
1437
|
+
if (state.status !== "clear")
|
|
1438
|
+
return state;
|
|
1439
|
+
await clearStale(found);
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
/** The intent in charge, from storage alone. */
|
|
1443
|
+
const route = () => {
|
|
1444
|
+
const found = mirrored();
|
|
1445
|
+
return found && found.intent.peek().status !== "clear"
|
|
1446
|
+
? found.intent
|
|
1447
|
+
: undefined;
|
|
1448
|
+
};
|
|
1449
|
+
return {
|
|
1450
|
+
...own,
|
|
1451
|
+
peek: () => route()?.peek() ?? own.peek(),
|
|
1452
|
+
async state(client, checks) {
|
|
1453
|
+
return (await intentState(client)) ?? own.state(client, checks);
|
|
1454
|
+
},
|
|
1455
|
+
async send(client, run) {
|
|
1456
|
+
const held = await intentState(client);
|
|
1457
|
+
if (held)
|
|
1458
|
+
throw new WriteHeldError(held);
|
|
1459
|
+
return own.send(client, run);
|
|
1460
|
+
},
|
|
1461
|
+
async exit(snapshot, acknowledgement, client) {
|
|
1462
|
+
const found = mirrored();
|
|
1463
|
+
if (!found || found.intent.peek().status === "clear")
|
|
1464
|
+
return own.exit(snapshot, acknowledgement, client);
|
|
1465
|
+
const ended = await found.intent.exit(snapshot, acknowledgement, client);
|
|
1466
|
+
if (ended.released)
|
|
1467
|
+
await clearStale(found);
|
|
1468
|
+
return ended;
|
|
1469
|
+
},
|
|
1470
|
+
onReceipt: (...args) => (route() ?? own).onReceipt(...args),
|
|
1471
|
+
onUnproven: (...args) => (route() ?? own).onUnproven(...args),
|
|
1472
|
+
onExecuted: (...args) => (route() ?? own).onExecuted(...args),
|
|
1473
|
+
attachHash: (...args) => (route() ?? own).attachHash(...args),
|
|
1474
|
+
attachSafeNonce: (...args) => (route() ?? own).attachSafeNonce(...args),
|
|
1475
|
+
// An intent's mirror already holds it: nothing is adopted.
|
|
1476
|
+
adopt: async (...args) => route()?.peek() ?? own.adopt(...args),
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
865
1479
|
return {
|
|
866
1480
|
open,
|
|
867
1481
|
/** Whether holds outlive this tab right now. */
|
|
@@ -869,14 +1483,7 @@ export function createWriteHold(options = {}) {
|
|
|
869
1483
|
/** The review's line while holds last only in this tab, or null. */
|
|
870
1484
|
reviewLine: () => (storage.durable() ? null : WRITE_HOLD_IN_TAB_LINE),
|
|
871
1485
|
/** Stop every settle poll and the tab listener (tests, teardown). */
|
|
872
|
-
dispose
|
|
873
|
-
for (const timer of timers.values())
|
|
874
|
-
clearTimeout(timer);
|
|
875
|
-
timers.clear();
|
|
876
|
-
if (onStorage)
|
|
877
|
-
globalThis.removeEventListener?.("storage", onStorage);
|
|
878
|
-
onStorage = undefined;
|
|
879
|
-
},
|
|
1486
|
+
dispose,
|
|
880
1487
|
};
|
|
881
1488
|
}
|
|
882
1489
|
//# sourceMappingURL=writeHold.js.map
|