@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,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.WriteHoldBusyError = exports.WriteHoldNonceError = exports.WriteHeldError = void 0;
|
|
3
|
+
exports.WriteHoldBusyError = exports.WriteHoldStaleReviewError = exports.WriteHoldNonceError = exports.WriteHeldError = void 0;
|
|
4
4
|
exports.createWriteHold = createWriteHold;
|
|
5
5
|
const viem_1 = require("viem");
|
|
6
6
|
const safeService_js_1 = require("../safeService.js");
|
|
@@ -8,6 +8,18 @@ const messages_js_1 = require("./messages.js");
|
|
|
8
8
|
const writeRecovery_js_1 = require("./writeRecovery.js");
|
|
9
9
|
const untrusted_js_1 = require("../untrusted.js");
|
|
10
10
|
const relayr_js_1 = require("./relayr.js");
|
|
11
|
+
const tabState_js_1 = require("./tabState.js");
|
|
12
|
+
/** `sent` is the transaction at `hash`, exactly `record`'s call, from its account, on its chain. */
|
|
13
|
+
function sentIsCall(sent, hash, record) {
|
|
14
|
+
return (sent.hash?.toLowerCase() === hash.toLowerCase() &&
|
|
15
|
+
sent.from?.toLowerCase() === record.account &&
|
|
16
|
+
(sent.chainId === undefined || sent.chainId === record.chainId) &&
|
|
17
|
+
sent.to?.toLowerCase() === record.call.to &&
|
|
18
|
+
sent.input?.toLowerCase() === record.call.data &&
|
|
19
|
+
BigInt(sent.value) === BigInt(record.call.value));
|
|
20
|
+
}
|
|
21
|
+
/** The stop of every controller with a follower or tab listener running, for the test-only reset. */
|
|
22
|
+
const liveHolds = (0, tabState_js_1.tabShared)(tabState_js_1.TAB.holds, () => new Set());
|
|
11
23
|
/**
|
|
12
24
|
* A state read without the chain (peek, a replay, another tab's change, a
|
|
13
25
|
* send's progress): its status and line, never the exit. Only the gate in
|
|
@@ -36,6 +48,19 @@ class WriteHoldNonceError extends Error {
|
|
|
36
48
|
}
|
|
37
49
|
}
|
|
38
50
|
exports.WriteHoldNonceError = WriteHoldNonceError;
|
|
51
|
+
/**
|
|
52
|
+
* The review went stale: another window's attempt of the same write was
|
|
53
|
+
* reserved, and maybe settled (`settled`), while it was open. Review again;
|
|
54
|
+
* the next review sends normally.
|
|
55
|
+
*/
|
|
56
|
+
class WriteHoldStaleReviewError extends Error {
|
|
57
|
+
constructor(settled) {
|
|
58
|
+
super(messages_js_1.WRITE_HOLD_STALE_REVIEW_LINE);
|
|
59
|
+
this.settled = settled;
|
|
60
|
+
this.name = "WriteHoldStaleReviewError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.WriteHoldStaleReviewError = WriteHoldStaleReviewError;
|
|
39
64
|
/** The exact call is already being sent from another flow or tab. */
|
|
40
65
|
class WriteHoldBusyError extends Error {
|
|
41
66
|
constructor() {
|
|
@@ -45,6 +70,8 @@ class WriteHoldBusyError extends Error {
|
|
|
45
70
|
}
|
|
46
71
|
exports.WriteHoldBusyError = WriteHoldBusyError;
|
|
47
72
|
const SETTLE_POLL_MS = 15_000;
|
|
73
|
+
/** How long a reservation retries a lock another writer briefly holds. */
|
|
74
|
+
const RESERVE_RETRY_MS = 2_000;
|
|
48
75
|
/** A Safe's execution events, in its 1.3 and 1.4 layouts alike. */
|
|
49
76
|
const SAFE_EXECUTION_TOPICS = [
|
|
50
77
|
(0, viem_1.toEventSelector)("ExecutionSuccess(bytes32,uint256)"),
|
|
@@ -52,6 +79,8 @@ const SAFE_EXECUTION_TOPICS = [
|
|
|
52
79
|
];
|
|
53
80
|
/** A hash the network still hasn't seen this long after the wallet returned it was almost certainly never broadcast. */
|
|
54
81
|
const UNSEEN_HASH_MS = 30 * 60_000;
|
|
82
|
+
/** The longest one request to Safe's service may take inside the hold. */
|
|
83
|
+
const SAFE_SERVICE_READ_MS = 8_000;
|
|
55
84
|
/** 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. */
|
|
56
85
|
function unseenTooLong(record) {
|
|
57
86
|
return (Date.now() - Number(record.submittedAt ?? record.firstSeenAt) >=
|
|
@@ -99,8 +128,12 @@ function unchecked(record) {
|
|
|
99
128
|
}
|
|
100
129
|
/** About an hour of polls; a later state() looks again. */
|
|
101
130
|
const SETTLE_POLL_ATTEMPTS = 240;
|
|
102
|
-
function clear(settled) {
|
|
103
|
-
return
|
|
131
|
+
function clear(settled, reservation) {
|
|
132
|
+
return {
|
|
133
|
+
status: "clear",
|
|
134
|
+
...(settled ? { settled } : {}),
|
|
135
|
+
...(reservation === undefined ? {} : { reservation }),
|
|
136
|
+
};
|
|
104
137
|
}
|
|
105
138
|
function ranExecuted(record, executionHash = record.executionHash, result) {
|
|
106
139
|
return {
|
|
@@ -164,12 +197,47 @@ function classify(record) {
|
|
|
164
197
|
*/
|
|
165
198
|
function createWriteHold(options = {}) {
|
|
166
199
|
const storage = options.storage ?? (0, writeRecovery_js_1.createHeldWriteStorage)();
|
|
200
|
+
/**
|
|
201
|
+
* Safe's service, every request and its body bounded together to
|
|
202
|
+
* {@link SAFE_SERVICE_READ_MS} whatever its fetch does with the signal: a read that times out is
|
|
203
|
+
* unknown, which never releases or settles, so a hung service can't hold
|
|
204
|
+
* a send or the call's lock.
|
|
205
|
+
*/
|
|
206
|
+
const service = {
|
|
207
|
+
...options.safeService,
|
|
208
|
+
fetch: (url, init) => {
|
|
209
|
+
const request = options.safeService?.fetch ?? globalThis.fetch;
|
|
210
|
+
let timer;
|
|
211
|
+
return Promise.race([
|
|
212
|
+
// The whole read, its body included, under the one deadline.
|
|
213
|
+
request(url, init).then(async (response) => new Response(await response.arrayBuffer(), {
|
|
214
|
+
status: response.status,
|
|
215
|
+
statusText: response.statusText,
|
|
216
|
+
headers: response.headers,
|
|
217
|
+
})),
|
|
218
|
+
new Promise((_, reject) => {
|
|
219
|
+
timer = setTimeout(() => reject(new Error("Safe's service did not answer in time.")), SAFE_SERVICE_READ_MS);
|
|
220
|
+
}),
|
|
221
|
+
]).finally(() => clearTimeout(timer));
|
|
222
|
+
},
|
|
223
|
+
};
|
|
167
224
|
const interval = options.settle?.intervalMs ?? SETTLE_POLL_MS;
|
|
168
225
|
const attempts = options.settle?.attempts ?? SETTLE_POLL_ATTEMPTS;
|
|
169
226
|
const listeners = new Map();
|
|
170
227
|
const peekers = new Map();
|
|
171
228
|
const timers = new Map();
|
|
172
229
|
let onStorage;
|
|
230
|
+
/**
|
|
231
|
+
* The one decorator every state leaves through for its storage notice:
|
|
232
|
+
* while holds last in this tab alone, a held state carries
|
|
233
|
+
* {@link WRITE_HOLD_IN_TAB_LINE}; otherwise none.
|
|
234
|
+
*/
|
|
235
|
+
function noticed(state) {
|
|
236
|
+
const { notice: _, ...rest } = state;
|
|
237
|
+
return state.status !== "clear" && !storage.durable()
|
|
238
|
+
? { ...rest, notice: messages_js_1.WRITE_HOLD_IN_TAB_LINE }
|
|
239
|
+
: rest;
|
|
240
|
+
}
|
|
173
241
|
/** The latest state emitted for each call, replayed to a late subscriber. */
|
|
174
242
|
const latest = new Map();
|
|
175
243
|
function emit(key, state) {
|
|
@@ -182,7 +250,9 @@ function createWriteHold(options = {}) {
|
|
|
182
250
|
if (onStorage || typeof globalThis.addEventListener !== "function")
|
|
183
251
|
return;
|
|
184
252
|
onStorage = (event) => {
|
|
185
|
-
if (event.key &&
|
|
253
|
+
if (event.key &&
|
|
254
|
+
(!event.key.startsWith("nana-sdk:reviewed-write") ||
|
|
255
|
+
event.key === "nana-sdk:reviewed-write:probe"))
|
|
186
256
|
return;
|
|
187
257
|
for (const [key, peek] of peekers) {
|
|
188
258
|
try {
|
|
@@ -194,11 +264,25 @@ function createWriteHold(options = {}) {
|
|
|
194
264
|
}
|
|
195
265
|
};
|
|
196
266
|
globalThis.addEventListener("storage", onStorage);
|
|
267
|
+
liveHolds.add(dispose);
|
|
197
268
|
}
|
|
198
|
-
|
|
269
|
+
/** Stop every settle poll and the tab listener. */
|
|
270
|
+
function dispose() {
|
|
271
|
+
for (const timer of timers.values())
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
timers.clear();
|
|
274
|
+
if (onStorage)
|
|
275
|
+
globalThis.removeEventListener?.("storage", onStorage);
|
|
276
|
+
onStorage = undefined;
|
|
277
|
+
liveHolds.delete(dispose);
|
|
278
|
+
}
|
|
279
|
+
function openOne(call, hooks = {},
|
|
280
|
+
/** The key a found write was saved under, when it isn't its call's own. */
|
|
281
|
+
at) {
|
|
199
282
|
const recovery = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
|
|
200
283
|
storage,
|
|
201
284
|
locks: options.locks,
|
|
285
|
+
at,
|
|
202
286
|
});
|
|
203
287
|
// The canonical recovery scope: a namespace and an exact call never share it.
|
|
204
288
|
const key = recovery.scopeKey;
|
|
@@ -206,11 +290,26 @@ function createWriteHold(options = {}) {
|
|
|
206
290
|
let lastEffect;
|
|
207
291
|
/** Nothing held: how the call's last write settled, if saved. */
|
|
208
292
|
const cleared = () => clear(recovery.settled());
|
|
293
|
+
/** Every state leaves with the reservation it belongs to. */
|
|
294
|
+
const tagged = (state) => {
|
|
295
|
+
const reservation = state.reservation ??
|
|
296
|
+
(state.status === "clear"
|
|
297
|
+
? recovery.latestReservation()
|
|
298
|
+
: state.record.id);
|
|
299
|
+
return noticed({
|
|
300
|
+
...state,
|
|
301
|
+
...(reservation === undefined ? {} : { reservation }),
|
|
302
|
+
});
|
|
303
|
+
};
|
|
209
304
|
const peek = () => {
|
|
210
305
|
const record = recovery.read();
|
|
211
|
-
return record ? ungated(classify(record)) : cleared();
|
|
306
|
+
return tagged(record ? ungated(classify(record)) : cleared());
|
|
307
|
+
};
|
|
308
|
+
const shown = (state) => {
|
|
309
|
+
const out = tagged(state);
|
|
310
|
+
emit(key, out);
|
|
311
|
+
return out;
|
|
212
312
|
};
|
|
213
|
-
const shown = (state) => (emit(key, state), state);
|
|
214
313
|
async function locked(work, wait) {
|
|
215
314
|
let entered = false;
|
|
216
315
|
try {
|
|
@@ -240,6 +339,7 @@ function createWriteHold(options = {}) {
|
|
|
240
339
|
function follow(client, id, left = attempts) {
|
|
241
340
|
if (timers.has(id) || left <= 0)
|
|
242
341
|
return;
|
|
342
|
+
liveHolds.add(dispose);
|
|
243
343
|
timers.set(id, setTimeout(() => {
|
|
244
344
|
void state(client)
|
|
245
345
|
.catch(() => null)
|
|
@@ -259,7 +359,7 @@ function createWriteHold(options = {}) {
|
|
|
259
359
|
};
|
|
260
360
|
// Kept for any later reader: another tab, a reload, a follow timer.
|
|
261
361
|
recovery.markSettled(record, settled);
|
|
262
|
-
return clear(settled);
|
|
362
|
+
return clear(settled, record.id);
|
|
263
363
|
}
|
|
264
364
|
if (!record.safe &&
|
|
265
365
|
receipt.status === "reverted" &&
|
|
@@ -281,10 +381,37 @@ function createWriteHold(options = {}) {
|
|
|
281
381
|
follow(client, record.id);
|
|
282
382
|
return classify(recovery.read());
|
|
283
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Whether a previous deploy's `hash` may be this call's: a valid hash
|
|
386
|
+
* read on the call's chain that isn't shown to be another write. A
|
|
387
|
+
* transaction must be the exact call from the account; a Safe proposal,
|
|
388
|
+
* exactly this call. One that can't be read is unknown, not refused.
|
|
389
|
+
*/
|
|
390
|
+
async function adoptable(hash, client) {
|
|
391
|
+
if (!(0, untrusted_js_1.isBytes32)(hash) ||
|
|
392
|
+
!client ||
|
|
393
|
+
(await client.getChainId().catch(() => null)) !== call.chainId)
|
|
394
|
+
return false;
|
|
395
|
+
const reviewed = {
|
|
396
|
+
account: call.account.toLowerCase(),
|
|
397
|
+
chainId: call.chainId,
|
|
398
|
+
call: {
|
|
399
|
+
to: call.call.to.toLowerCase(),
|
|
400
|
+
data: call.call.data.toLowerCase(),
|
|
401
|
+
value: String(call.call.value ?? 0),
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
if (call.safe) {
|
|
405
|
+
const proposal = await (0, safeService_js_1.readSafeTransaction)(call.chainId, call.account, hash, service).catch(() => null);
|
|
406
|
+
return !proposal || (0, writeRecovery_js_1.safeProposalIsHeldCall)(proposal, call);
|
|
407
|
+
}
|
|
408
|
+
const sent = await client.getTransaction({ hash }).catch(() => null);
|
|
409
|
+
return !sent || sentIsCall(sent, hash, reviewed);
|
|
410
|
+
}
|
|
284
411
|
/** The authenticated nonce of a Safe proposal, from Safe's service. */
|
|
285
412
|
async function proposalNonceOf(record) {
|
|
286
413
|
try {
|
|
287
|
-
const proposal = await (0, safeService_js_1.readSafeTransaction)(record.chainId, record.account, record.hash,
|
|
414
|
+
const proposal = await (0, safeService_js_1.readSafeTransaction)(record.chainId, record.account, record.hash, service);
|
|
288
415
|
return (0, safeService_js_1.safeTransactionMessage)(proposal).nonce;
|
|
289
416
|
}
|
|
290
417
|
catch {
|
|
@@ -351,8 +478,22 @@ function createWriteHold(options = {}) {
|
|
|
351
478
|
// Only positive pending evidence stops the clock; a read that fails
|
|
352
479
|
// keeps the one saved.
|
|
353
480
|
if (look === "pending")
|
|
354
|
-
return {
|
|
481
|
+
return {
|
|
482
|
+
...state,
|
|
483
|
+
record: recovery.seenPending(recovery.mined(state.record, false)),
|
|
484
|
+
};
|
|
355
485
|
const record = look === "mined" ? recovery.mined(state.record, true) : state.record;
|
|
486
|
+
// Unknown or not found is not pending: with no read showing it pending
|
|
487
|
+
// or mined for 30 minutes (since it was last seen pending, or else
|
|
488
|
+
// submitted), the warned explicit exit, never a release on its own. A
|
|
489
|
+
// Safe proposal's hash isn't a transaction to read.
|
|
490
|
+
if ((look === "unknown" || look === null) &&
|
|
491
|
+
!record.safe &&
|
|
492
|
+
record.minedAt === undefined &&
|
|
493
|
+
Date.now() -
|
|
494
|
+
Number(record.pendingSeenAt ?? record.submittedAt ?? record.firstSeenAt) >=
|
|
495
|
+
UNSEEN_HASH_MS)
|
|
496
|
+
return unchecked(record);
|
|
356
497
|
if (record.minedAt === undefined)
|
|
357
498
|
return state;
|
|
358
499
|
if (Date.now() - Number(record.minedAt) >= UNSEEN_HASH_MS)
|
|
@@ -361,7 +502,9 @@ function createWriteHold(options = {}) {
|
|
|
361
502
|
}
|
|
362
503
|
/** What the node shows of `hash`: positive pending or mined metadata, or nothing it can say. */
|
|
363
504
|
async function lookAt(hash, client) {
|
|
364
|
-
const sent = await client.getTransaction({ hash }).
|
|
505
|
+
const sent = await client.getTransaction({ hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
|
|
506
|
+
if (sent === undefined)
|
|
507
|
+
return "unknown";
|
|
365
508
|
if (sent?.hash?.toLowerCase() !== hash)
|
|
366
509
|
return null;
|
|
367
510
|
return sent.blockNumber === null ? "pending" : "mined";
|
|
@@ -382,7 +525,8 @@ function createWriteHold(options = {}) {
|
|
|
382
525
|
for (const hash of seen) {
|
|
383
526
|
const own = hash === record.hash;
|
|
384
527
|
const look = await lookAt(hash, client);
|
|
385
|
-
|
|
528
|
+
// Unknown changes nothing: only positive metadata moves a clock.
|
|
529
|
+
if (look === "pending" || look === "mined")
|
|
386
530
|
record = own
|
|
387
531
|
? recovery.mined(record, look === "mined")
|
|
388
532
|
: recovery.candidateMined(record, hash, look === "mined");
|
|
@@ -472,15 +616,15 @@ function createWriteHold(options = {}) {
|
|
|
472
616
|
}
|
|
473
617
|
const end = (record.safeNonce !== undefined &&
|
|
474
618
|
(await (0, safeService_js_1.provenSafeProposalEnd)(client, record.account, hash, BigInt(record.safeNonce)))) ||
|
|
475
|
-
(await (0, safeService_js_1.lookAtSafeProposalEnd)(client, record.chainId, record.account, hash,
|
|
619
|
+
(await (0, safeService_js_1.lookAtSafeProposalEnd)(client, record.chainId, record.account, hash, service));
|
|
476
620
|
// Another Safe transaction took its nonce, even after a saved execution
|
|
477
621
|
// was reorganized away. It may have run the call too: the exit, never a release.
|
|
478
622
|
if (end.look === "replaced")
|
|
479
623
|
return safeReplaced(recovery.replacedOnChain(record));
|
|
480
624
|
if (end.look === "expired" && !record.executed) {
|
|
481
|
-
const expired = await (0, writeRecovery_js_1.verifyReviewedWriteExpiry)(client, record,
|
|
625
|
+
const expired = await (0, writeRecovery_js_1.verifyReviewedWriteExpiry)(client, record, service).catch(() => false);
|
|
482
626
|
if (expired && release(record))
|
|
483
|
-
return clear({ result: "expired" });
|
|
627
|
+
return clear({ result: "expired" }, record.id);
|
|
484
628
|
}
|
|
485
629
|
if (end.look === "executed") {
|
|
486
630
|
if (!record.executed ||
|
|
@@ -502,7 +646,7 @@ function createWriteHold(options = {}) {
|
|
|
502
646
|
hash: end.execution.executionHash,
|
|
503
647
|
};
|
|
504
648
|
recovery.markSettled(record, settled);
|
|
505
|
-
return clear(settled);
|
|
649
|
+
return clear(settled, record.id);
|
|
506
650
|
}
|
|
507
651
|
}
|
|
508
652
|
if (!record.executed &&
|
|
@@ -543,6 +687,9 @@ function createWriteHold(options = {}) {
|
|
|
543
687
|
let record = recovery.read();
|
|
544
688
|
if (!record)
|
|
545
689
|
return cleared();
|
|
690
|
+
// A mirror carries no evidence of its own: its source attempt does.
|
|
691
|
+
if (record.mirrorOf)
|
|
692
|
+
return classify(record);
|
|
546
693
|
// No evidence from another chain.
|
|
547
694
|
if ((await client.getChainId().catch(() => null)) !== record.chainId)
|
|
548
695
|
return classify(record);
|
|
@@ -606,9 +753,7 @@ function createWriteHold(options = {}) {
|
|
|
606
753
|
}
|
|
607
754
|
if (record.hash &&
|
|
608
755
|
record.sentNonce === undefined &&
|
|
609
|
-
(record
|
|
610
|
-
record.fromBlock === undefined ||
|
|
611
|
-
unseenTooLong(record)) &&
|
|
756
|
+
unseenTooLong(record) &&
|
|
612
757
|
(await client.getTransaction({ hash: record.hash }).then(() => false, (error) => error?.name === "TransactionNotFoundError")))
|
|
613
758
|
return unchecked(record);
|
|
614
759
|
return classify(record);
|
|
@@ -620,7 +765,7 @@ function createWriteHold(options = {}) {
|
|
|
620
765
|
async function state(client, checks = {}) {
|
|
621
766
|
lastEffect = checks.effect;
|
|
622
767
|
const result = await locked(() => inspect(client, checks.effect), false);
|
|
623
|
-
return shown(result ? result.value : peek());
|
|
768
|
+
return { ...shown(result ? result.value : peek()), checked: !!result };
|
|
624
769
|
}
|
|
625
770
|
return {
|
|
626
771
|
state,
|
|
@@ -644,8 +789,11 @@ function createWriteHold(options = {}) {
|
|
|
644
789
|
if (last) {
|
|
645
790
|
const now = peek();
|
|
646
791
|
// A clear is compared by its settlement's identity, a hold by its record.
|
|
647
|
-
const identity = (state) => JSON.stringify(
|
|
648
|
-
|
|
792
|
+
const identity = (state) => JSON.stringify([
|
|
793
|
+
state.reservation ?? null,
|
|
794
|
+
state.status === "clear" ? (state.settled ?? null) : state.record,
|
|
795
|
+
]);
|
|
796
|
+
listener(noticed(ungated(identity(now) === identity(last) ? last : now)));
|
|
649
797
|
}
|
|
650
798
|
return () => {
|
|
651
799
|
set.delete(listener);
|
|
@@ -664,6 +812,10 @@ function createWriteHold(options = {}) {
|
|
|
664
812
|
*/
|
|
665
813
|
async send(client, run) {
|
|
666
814
|
let attempt;
|
|
815
|
+
let ended;
|
|
816
|
+
const until = new Promise((resolve) => (ended = resolve));
|
|
817
|
+
let released;
|
|
818
|
+
let reviewed = "";
|
|
667
819
|
const gate = {
|
|
668
820
|
async beforeSend(actualCall) {
|
|
669
821
|
let evidence;
|
|
@@ -677,12 +829,31 @@ function createWriteHold(options = {}) {
|
|
|
677
829
|
throw new WriteHoldNonceError(cause);
|
|
678
830
|
}
|
|
679
831
|
}
|
|
680
|
-
|
|
832
|
+
const reserve = () => {
|
|
681
833
|
attempt = recovery.reserve(actualCall, evidence);
|
|
834
|
+
};
|
|
835
|
+
try {
|
|
836
|
+
if (hooks.across)
|
|
837
|
+
({ released } = await hooks.across((work) => recovery.withLock(work), reserve, () => {
|
|
838
|
+
recovery.rejected();
|
|
839
|
+
attempt = undefined;
|
|
840
|
+
}, until, reviewed));
|
|
841
|
+
else
|
|
842
|
+
reserve();
|
|
682
843
|
}
|
|
683
844
|
catch (error) {
|
|
684
|
-
|
|
685
|
-
|
|
845
|
+
if (error instanceof WriteHeldError ||
|
|
846
|
+
error instanceof WriteHoldBusyError ||
|
|
847
|
+
error instanceof WriteHoldStaleReviewError)
|
|
848
|
+
throw error;
|
|
849
|
+
if (hooks.across) {
|
|
850
|
+
// No lock is held here: what the saved record says.
|
|
851
|
+
const now = shown(peek());
|
|
852
|
+
if (now.status !== "clear")
|
|
853
|
+
throw new WriteHeldError(now);
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
const now = shown(await finish(classify(recovery.read()), client));
|
|
686
857
|
if (now.status !== "clear")
|
|
687
858
|
throw new WriteHeldError(now);
|
|
688
859
|
throw error;
|
|
@@ -717,6 +888,25 @@ function createWriteHold(options = {}) {
|
|
|
717
888
|
shown(peek());
|
|
718
889
|
},
|
|
719
890
|
};
|
|
891
|
+
if (hooks.across) {
|
|
892
|
+
// Checked under this key's lock, which is then let go: the final
|
|
893
|
+
// check and reservation take every lock again, in order.
|
|
894
|
+
const checked = await locked(async () => shown(await inspect(client)), false);
|
|
895
|
+
if (!checked)
|
|
896
|
+
throw new WriteHoldBusyError();
|
|
897
|
+
if (checked.value.status !== "clear")
|
|
898
|
+
throw new WriteHeldError(checked.value);
|
|
899
|
+
// Given with `across`: the generations the review is checked against.
|
|
900
|
+
reviewed = hooks.generations();
|
|
901
|
+
try {
|
|
902
|
+
return await run(gate);
|
|
903
|
+
}
|
|
904
|
+
finally {
|
|
905
|
+
// The send ends: every lock it reserved under is let go first.
|
|
906
|
+
ended();
|
|
907
|
+
await released;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
720
910
|
const result = await locked(async () => {
|
|
721
911
|
const now = shown(await inspect(client));
|
|
722
912
|
if (now.status !== "clear")
|
|
@@ -753,11 +943,18 @@ function createWriteHold(options = {}) {
|
|
|
753
943
|
state: await finish(classify(recovery.read()), client),
|
|
754
944
|
};
|
|
755
945
|
}
|
|
756
|
-
return {
|
|
757
|
-
|
|
946
|
+
return {
|
|
947
|
+
released: true,
|
|
948
|
+
state: clear(undefined, snapshot.record.id),
|
|
949
|
+
};
|
|
950
|
+
// Never waits behind another send's wallet prompt: on contention,
|
|
951
|
+
// what the saved record says, with no exit.
|
|
952
|
+
}, false);
|
|
758
953
|
const ended = result?.value ?? { released: false, state: peek() };
|
|
759
|
-
|
|
760
|
-
|
|
954
|
+
return {
|
|
955
|
+
...ended,
|
|
956
|
+
state: { ...shown(ended.state), checked: !!result },
|
|
957
|
+
};
|
|
761
958
|
},
|
|
762
959
|
/**
|
|
763
960
|
* A receipt for the held write: an ordinary write's own hash, or a Safe
|
|
@@ -768,6 +965,7 @@ function createWriteHold(options = {}) {
|
|
|
768
965
|
const result = await locked(async () => {
|
|
769
966
|
const record = recovery.read();
|
|
770
967
|
if (!record ||
|
|
968
|
+
record.mirrorOf ||
|
|
771
969
|
(!record.safe &&
|
|
772
970
|
record.hash !== receipt?.transactionHash?.toLowerCase()))
|
|
773
971
|
return finish(classify(record), client);
|
|
@@ -800,23 +998,108 @@ function createWriteHold(options = {}) {
|
|
|
800
998
|
* one lock, without releasing it. The hold then follows that hash: no
|
|
801
999
|
* exit while it is pending, and its receipt settles it.
|
|
802
1000
|
*/
|
|
1001
|
+
/**
|
|
1002
|
+
* Move a write a previous deploy kept (outside these holds) into this
|
|
1003
|
+
* hold. `snapshot` is the state the app read before deciding. Under
|
|
1004
|
+
* the call's lock, it reserves a held record with `evidence` (the
|
|
1005
|
+
* account's next nonce, or the nonce it was sent at, with the block it
|
|
1006
|
+
* was read at; when it was sent; and its `hash`, or for a Safe write
|
|
1007
|
+
* `safe.safeTxHash`) only while nothing is held and no reservation
|
|
1008
|
+
* came since `snapshot`: another tab's hold or settlement wins, and
|
|
1009
|
+
* the current state is returned unchanged. A hash is checked first
|
|
1010
|
+
* with `client`: a transaction that is another call, from another
|
|
1011
|
+
* account or on another chain (or a Safe proposal of another call) is
|
|
1012
|
+
* refused, adopting nothing. One the chain can't show is held, with
|
|
1013
|
+
* the warned exit 30 minutes after it was sent (or adopted). Then the
|
|
1014
|
+
* gate reads it: a mined one settles on its verified receipt. It never
|
|
1015
|
+
* sends or releases; adopting twice holds once.
|
|
1016
|
+
*/
|
|
1017
|
+
async adopt(snapshot, evidence = {}, client) {
|
|
1018
|
+
const nonce = evidence.nonce ?? evidence.sentNonce;
|
|
1019
|
+
const hash = (call.safe ? evidence.safe?.safeTxHash : evidence.hash)?.toLowerCase();
|
|
1020
|
+
/** Nothing held, and no reservation since the app's read. */
|
|
1021
|
+
const free = () => snapshot.status === "clear" &&
|
|
1022
|
+
!recovery.read() &&
|
|
1023
|
+
recovery.latestReservation() === snapshot.reservation;
|
|
1024
|
+
const reserve = () => recovery.reserve(undefined, nonce !== undefined && evidence.fromBlock !== undefined
|
|
1025
|
+
? { nonce, blockNumber: evidence.fromBlock }
|
|
1026
|
+
: undefined, {
|
|
1027
|
+
hash,
|
|
1028
|
+
submittedAt: evidence.submittedAt === undefined
|
|
1029
|
+
? undefined
|
|
1030
|
+
: String(evidence.submittedAt),
|
|
1031
|
+
});
|
|
1032
|
+
if (hooks.across) {
|
|
1033
|
+
// Under an intent: the same atomic reserve as a send, under every
|
|
1034
|
+
// legacy key's lock, mirrored at each so older versions see it.
|
|
1035
|
+
if (!free() ||
|
|
1036
|
+
(hash !== undefined && !(await adoptable(hash, client))))
|
|
1037
|
+
return shown(peek());
|
|
1038
|
+
let adopted = false;
|
|
1039
|
+
try {
|
|
1040
|
+
const { released } = await hooks.across((work) => recovery.withLock(work), () => {
|
|
1041
|
+
if (!free())
|
|
1042
|
+
throw new Error("Held since the app's read.");
|
|
1043
|
+
reserve();
|
|
1044
|
+
adopted = true;
|
|
1045
|
+
}, recovery.rejected, Promise.resolve(), hooks.generations());
|
|
1046
|
+
await released;
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
/* Held elsewhere, busy, or no longer free: nothing adopted. */
|
|
1050
|
+
}
|
|
1051
|
+
// A mined hash settles on its verified receipt, through the gate.
|
|
1052
|
+
return adopted && hash && client ? state(client) : shown(peek());
|
|
1053
|
+
}
|
|
1054
|
+
const result = await locked(async () => {
|
|
1055
|
+
if (!free())
|
|
1056
|
+
return peek();
|
|
1057
|
+
if (hash !== undefined && !(await adoptable(hash, client)))
|
|
1058
|
+
return peek();
|
|
1059
|
+
reserve();
|
|
1060
|
+
return hash && client ? inspect(client) : peek();
|
|
1061
|
+
}, true);
|
|
1062
|
+
return shown(result?.value ?? peek());
|
|
1063
|
+
},
|
|
1064
|
+
/**
|
|
1065
|
+
* Save the Safe nonce of a held Safe proposal adopted before it was
|
|
1066
|
+
* known, so a replacement can be proven later. Under the call's lock,
|
|
1067
|
+
* only while the held proposal is `safeTxHash` with no nonce yet, and
|
|
1068
|
+
* only when the call's proposal at `nonce` hashes to it: the hash is
|
|
1069
|
+
* the proof. Then the gate reads it. Never sends or releases.
|
|
1070
|
+
*/
|
|
1071
|
+
async attachSafeNonce(client, { safeTxHash, nonce }) {
|
|
1072
|
+
const result = await locked(async () => {
|
|
1073
|
+
const record = recovery.read();
|
|
1074
|
+
if (record?.safe &&
|
|
1075
|
+
!record.mirrorOf &&
|
|
1076
|
+
record.safeNonce === undefined &&
|
|
1077
|
+
record.hash === safeTxHash.toLowerCase())
|
|
1078
|
+
try {
|
|
1079
|
+
(0, safeService_js_1.canonicalSafeTxHash)(call.chainId, call.account, (0, writeRecovery_js_1.heldSafeProposal)(call, nonce), safeTxHash);
|
|
1080
|
+
recovery.proposalNonce(record, BigInt(nonce));
|
|
1081
|
+
}
|
|
1082
|
+
catch {
|
|
1083
|
+
/* Not this proposal at that nonce: nothing saved. */
|
|
1084
|
+
}
|
|
1085
|
+
return inspect(client);
|
|
1086
|
+
}, true);
|
|
1087
|
+
return shown(result?.value ?? peek());
|
|
1088
|
+
},
|
|
803
1089
|
async attachHash(client, hash) {
|
|
804
1090
|
const result = await locked(async () => {
|
|
805
1091
|
const record = recovery.read();
|
|
806
1092
|
if (record &&
|
|
807
1093
|
!record.safe &&
|
|
808
1094
|
!record.hash &&
|
|
1095
|
+
!record.mirrorOf &&
|
|
809
1096
|
(0, untrusted_js_1.isBytes32)(hash) &&
|
|
810
1097
|
(await client.getChainId().catch(() => null)) === record.chainId) {
|
|
811
1098
|
const sent = await client
|
|
812
1099
|
.getTransaction({ hash })
|
|
813
1100
|
.catch(() => null);
|
|
814
|
-
if (sent
|
|
815
|
-
sent
|
|
816
|
-
(sent.chainId === undefined || sent.chainId === record.chainId) &&
|
|
817
|
-
sent.to?.toLowerCase() === record.call.to &&
|
|
818
|
-
sent.input?.toLowerCase() === record.call.data &&
|
|
819
|
-
BigInt(sent.value) === BigInt(record.call.value) &&
|
|
1101
|
+
if (sent &&
|
|
1102
|
+
sentIsCall(sent, hash, record) &&
|
|
820
1103
|
// This attempt, not an earlier identical call: never below the
|
|
821
1104
|
// account's next nonce or mined before it was reserved.
|
|
822
1105
|
// With no such evidence, an identical earlier call can't be
|
|
@@ -869,6 +1152,338 @@ function createWriteHold(options = {}) {
|
|
|
869
1152
|
},
|
|
870
1153
|
};
|
|
871
1154
|
}
|
|
1155
|
+
/**
|
|
1156
|
+
* `legacy`: for a write held under an intent namespace, the keys an earlier
|
|
1157
|
+
* version held it under (an old namespace, or `{ call }` for an exact
|
|
1158
|
+
* call); the current exact call is always checked. `{ match: { to,
|
|
1159
|
+
* selector } }` finds them instead: every exact-call write this account
|
|
1160
|
+
* saved on this chain to `to` with that 4-byte selector, for a hold saved
|
|
1161
|
+
* before the intent key existed under calldata the retry can't rebuild (an
|
|
1162
|
+
* old minimum, expiration or deadline). Pass one only for a write whose
|
|
1163
|
+
* calldata is volatile: it holds every such call. It is found when the
|
|
1164
|
+
* handle opens, and again right before a send reserves. While any of them holds
|
|
1165
|
+
* an unresolved write, the intent handle *is* that hold: its state, its
|
|
1166
|
+
* refusal and its own exit, in place. Each is checked alone, under its own
|
|
1167
|
+
* lock only, never two at once. Only when all are clear does the intent's
|
|
1168
|
+
* own record apply, and only then can a send reserve; they are checked
|
|
1169
|
+
* again right before it does.
|
|
1170
|
+
*/
|
|
1171
|
+
function open(call, { legacy = [], } = {}) {
|
|
1172
|
+
if (call.namespace === undefined)
|
|
1173
|
+
return openExact(call);
|
|
1174
|
+
const matches = legacy.flatMap((source) => typeof source === "object" && "match" in source ? [source.match] : []);
|
|
1175
|
+
/** Every exact-call write the matches find in storage now. */
|
|
1176
|
+
const matched = () => matches.flatMap((match) => (0, writeRecovery_js_1.findReviewedWrites)({ chainId: call.chainId, account: call.account, ...match }, { storage }));
|
|
1177
|
+
const namespace = call.namespace;
|
|
1178
|
+
const own = openOne(call, {
|
|
1179
|
+
across: reserveAcross,
|
|
1180
|
+
generations: () => generations(),
|
|
1181
|
+
});
|
|
1182
|
+
const ownJournal = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, { storage });
|
|
1183
|
+
/** Every involved key's latest reservation id, its generation. */
|
|
1184
|
+
const generations = () => JSON.stringify([ownJournal, ...journals].map((journal) => journal.latestReservation()));
|
|
1185
|
+
const { namespace: _, ...exact } = call;
|
|
1186
|
+
const scopeOf = (input) => (0, writeRecovery_js_1.createBrowserWriteRecovery)(input, { storage }).scopeKey;
|
|
1187
|
+
const scopes = new Set([scopeOf(call)]);
|
|
1188
|
+
// Mirror-aware: evidence and exits always act on the source attempt.
|
|
1189
|
+
const delegates = [];
|
|
1190
|
+
const journals = [];
|
|
1191
|
+
/** Hold `inputs` too, each once, in one canonical order by scope for every writer's locks. */
|
|
1192
|
+
function include(inputs) {
|
|
1193
|
+
const added = inputs.filter(({ input, at }) => {
|
|
1194
|
+
const scope = at ?? scopeOf(input);
|
|
1195
|
+
return !scopes.has(scope) && !!scopes.add(scope);
|
|
1196
|
+
});
|
|
1197
|
+
if (!added.length)
|
|
1198
|
+
return;
|
|
1199
|
+
const keyed = [
|
|
1200
|
+
...delegates.map((delegate, index) => ({
|
|
1201
|
+
delegate,
|
|
1202
|
+
journal: journals[index],
|
|
1203
|
+
})),
|
|
1204
|
+
...added.map(({ input, at }) => ({
|
|
1205
|
+
delegate: openExact(input, at),
|
|
1206
|
+
journal: (0, writeRecovery_js_1.createBrowserWriteRecovery)(input, {
|
|
1207
|
+
storage,
|
|
1208
|
+
locks: options.locks,
|
|
1209
|
+
at,
|
|
1210
|
+
}),
|
|
1211
|
+
})),
|
|
1212
|
+
].sort((a, b) => Number(a.journal.scopeKey > b.journal.scopeKey) -
|
|
1213
|
+
Number(a.journal.scopeKey < b.journal.scopeKey));
|
|
1214
|
+
delegates.splice(0, delegates.length, ...keyed.map((k) => k.delegate));
|
|
1215
|
+
journals.splice(0, journals.length, ...keyed.map((k) => k.journal));
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Look for matching writes again (on every state(), send() and exit());
|
|
1219
|
+
* those found since are held too, each at the exact key it was found
|
|
1220
|
+
* under, with its own Safe flag. Returns the ones that are new.
|
|
1221
|
+
*/
|
|
1222
|
+
function rescan() {
|
|
1223
|
+
const found = matched().filter(({ key }) => !scopes.has(key));
|
|
1224
|
+
include(found.map(({ key, record }) => ({
|
|
1225
|
+
input: { ...exact, safe: record.safe, call: record.call },
|
|
1226
|
+
at: key,
|
|
1227
|
+
})));
|
|
1228
|
+
return found.map(({ record }) => record);
|
|
1229
|
+
}
|
|
1230
|
+
include([{ call: call.call }, ...legacy].flatMap((source) => typeof source === "string"
|
|
1231
|
+
? [{ input: { ...call, namespace: source } }]
|
|
1232
|
+
: "call" in source
|
|
1233
|
+
? [{ input: { ...exact, call: source.call } }]
|
|
1234
|
+
: []));
|
|
1235
|
+
rescan();
|
|
1236
|
+
/** A legacy key holding anything but this intent's own mirror. */
|
|
1237
|
+
const foreign = (index) => journals[index].read()?.mirrorOf !== namespace &&
|
|
1238
|
+
delegates[index].peek().status !== "clear";
|
|
1239
|
+
/** The legacy hold the intent stands for right now, from storage alone. */
|
|
1240
|
+
const active = () => delegates.find((_, index) => foreign(index));
|
|
1241
|
+
/** The first legacy key holding an unresolved write, through its own gate. */
|
|
1242
|
+
async function delegated(client) {
|
|
1243
|
+
rescan();
|
|
1244
|
+
for (const [index, handle] of delegates.entries()) {
|
|
1245
|
+
if (!foreign(index))
|
|
1246
|
+
continue;
|
|
1247
|
+
const state = await handle.state(client);
|
|
1248
|
+
if (state.status !== "clear")
|
|
1249
|
+
return state;
|
|
1250
|
+
}
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* The final check and reservation, under every legacy key's lock taken
|
|
1255
|
+
* in one sorted order (the intent's own is held by its send), each
|
|
1256
|
+
* without waiting: a key another writer holds refuses as busy. A legacy
|
|
1257
|
+
* hold refuses; otherwise the intent reserves, and mirrors itself at each
|
|
1258
|
+
* legacy key so its readers, older versions included, see it held.
|
|
1259
|
+
*/
|
|
1260
|
+
async function reserveAcross(ownLock, reserve, unreserve, until, reviewed) {
|
|
1261
|
+
const locks = [
|
|
1262
|
+
{ scope: scopeOf(call), take: ownLock },
|
|
1263
|
+
...journals.map((journal) => ({
|
|
1264
|
+
scope: journal.scopeKey,
|
|
1265
|
+
take: (work) => journal.withLock(work),
|
|
1266
|
+
})),
|
|
1267
|
+
].sort((a, b) => Number(a.scope > b.scope) - Number(a.scope < b.scope));
|
|
1268
|
+
const deadline = Date.now() + RESERVE_RETRY_MS;
|
|
1269
|
+
for (;;) {
|
|
1270
|
+
let entered = 0;
|
|
1271
|
+
let reserved;
|
|
1272
|
+
const done = new Promise((resolve) => (reserved = resolve));
|
|
1273
|
+
const acquire = async (index) => {
|
|
1274
|
+
if (index < locks.length)
|
|
1275
|
+
return locks[index].take(async () => {
|
|
1276
|
+
entered += 1;
|
|
1277
|
+
return acquire(index + 1);
|
|
1278
|
+
});
|
|
1279
|
+
const refuse = (held) => new WriteHeldError(noticed({
|
|
1280
|
+
...ungated(classify(held)),
|
|
1281
|
+
reservation: held.id,
|
|
1282
|
+
}));
|
|
1283
|
+
for (const journal of journals) {
|
|
1284
|
+
const held = journal.read();
|
|
1285
|
+
if (held && held.mirrorOf !== namespace)
|
|
1286
|
+
throw refuse(held);
|
|
1287
|
+
}
|
|
1288
|
+
// A matching write saved since the last look holds it too, and
|
|
1289
|
+
// its own state() and exit() are reached from now on.
|
|
1290
|
+
const [late] = rescan();
|
|
1291
|
+
if (late)
|
|
1292
|
+
throw refuse(late);
|
|
1293
|
+
// Another attempt since this review opened: review it again.
|
|
1294
|
+
if (generations() !== reviewed)
|
|
1295
|
+
throw new WriteHoldStaleReviewError(ownJournal.settled());
|
|
1296
|
+
reserve();
|
|
1297
|
+
const record = own.peek().record;
|
|
1298
|
+
// Every mirror is saved, in the store the hold itself uses (its
|
|
1299
|
+
// memory fallback included), before the wallet opens; otherwise
|
|
1300
|
+
// this unsent reservation and its partial mirrors are undone.
|
|
1301
|
+
const mirrors = [];
|
|
1302
|
+
try {
|
|
1303
|
+
for (const journal of journals)
|
|
1304
|
+
mirrors.push([journal, journal.mirror(record, namespace)]);
|
|
1305
|
+
}
|
|
1306
|
+
catch (error) {
|
|
1307
|
+
for (const [journal, mirror] of mirrors)
|
|
1308
|
+
try {
|
|
1309
|
+
journal.clear(mirror);
|
|
1310
|
+
}
|
|
1311
|
+
catch {
|
|
1312
|
+
/* Held for old readers until their explicit exit. */
|
|
1313
|
+
}
|
|
1314
|
+
try {
|
|
1315
|
+
unreserve();
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
/* Unsent, it stays held with its explicit exit. */
|
|
1319
|
+
}
|
|
1320
|
+
throw error;
|
|
1321
|
+
}
|
|
1322
|
+
// Reserved: every lock is kept until the send ends.
|
|
1323
|
+
reserved();
|
|
1324
|
+
await until;
|
|
1325
|
+
};
|
|
1326
|
+
const holding = acquire(0);
|
|
1327
|
+
const failed = await Promise.race([
|
|
1328
|
+
done.then(() => null),
|
|
1329
|
+
holding.then(() => null, (error) => ({ error })),
|
|
1330
|
+
]);
|
|
1331
|
+
if (!failed)
|
|
1332
|
+
return { released: holding.catch(() => { }) };
|
|
1333
|
+
// Not reserved: every lock, its own included, is let go before any
|
|
1334
|
+
// retry; the checks run again each time.
|
|
1335
|
+
const { error } = failed;
|
|
1336
|
+
if (entered === locks.length ||
|
|
1337
|
+
error instanceof WriteHeldError ||
|
|
1338
|
+
error instanceof WriteHoldStaleReviewError)
|
|
1339
|
+
throw error;
|
|
1340
|
+
if (Date.now() >= deadline)
|
|
1341
|
+
throw new WriteHoldBusyError();
|
|
1342
|
+
await new Promise((resolve) => setTimeout(resolve, 25 + Math.random() * 75));
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
/** Remove this intent's mirrors of the reservation that ended, by its id only. */
|
|
1346
|
+
async function clearMirrors(ended) {
|
|
1347
|
+
for (const journal of journals) {
|
|
1348
|
+
if (journal.read()?.id !== ended)
|
|
1349
|
+
continue;
|
|
1350
|
+
await journal
|
|
1351
|
+
.withLock(async () => {
|
|
1352
|
+
const current = journal.read();
|
|
1353
|
+
if (current?.mirrorOf === namespace && current.id === ended)
|
|
1354
|
+
journal.clear(current);
|
|
1355
|
+
})
|
|
1356
|
+
.catch(() => {
|
|
1357
|
+
/* Kept for old readers until their explicit exit. */
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
/** After a call that ended this intent's reservation, its mirrors go. */
|
|
1362
|
+
const after = async (value) => {
|
|
1363
|
+
if (value.status === "clear" && value.reservation)
|
|
1364
|
+
await clearMirrors(value.reservation);
|
|
1365
|
+
return value;
|
|
1366
|
+
};
|
|
1367
|
+
return {
|
|
1368
|
+
...own,
|
|
1369
|
+
peek: () => active()?.peek() ?? own.peek(),
|
|
1370
|
+
async state(client, checks) {
|
|
1371
|
+
return ((await delegated(client)) ?? after(await own.state(client, checks)));
|
|
1372
|
+
},
|
|
1373
|
+
async send(client, run) {
|
|
1374
|
+
const held = await delegated(client);
|
|
1375
|
+
if (held)
|
|
1376
|
+
throw new WriteHeldError(held);
|
|
1377
|
+
return own.send(client, run);
|
|
1378
|
+
},
|
|
1379
|
+
async exit(snapshot, acknowledgement, client) {
|
|
1380
|
+
rescan();
|
|
1381
|
+
const owner = snapshot.status === "clear"
|
|
1382
|
+
? undefined
|
|
1383
|
+
: delegates.find((handle, index) => {
|
|
1384
|
+
const now = handle.peek();
|
|
1385
|
+
return (foreign(index) &&
|
|
1386
|
+
now.status !== "clear" &&
|
|
1387
|
+
now.record.id === snapshot.record.id);
|
|
1388
|
+
});
|
|
1389
|
+
if (owner)
|
|
1390
|
+
return owner.exit(snapshot, acknowledgement, client);
|
|
1391
|
+
const held = await delegated(client);
|
|
1392
|
+
if (held)
|
|
1393
|
+
return { released: false, state: held };
|
|
1394
|
+
const ended = await own.exit(snapshot, acknowledgement, client);
|
|
1395
|
+
await after(ended.state);
|
|
1396
|
+
return ended;
|
|
1397
|
+
},
|
|
1398
|
+
onReceipt: async (...args) => active()?.onReceipt(...args) ?? after(await own.onReceipt(...args)),
|
|
1399
|
+
onUnproven: async (...args) => active()?.onUnproven(...args) ?? after(await own.onUnproven(...args)),
|
|
1400
|
+
onExecuted: async (...args) => active()?.onExecuted(...args) ?? after(await own.onExecuted(...args)),
|
|
1401
|
+
attachHash: async (...args) => active()?.attachHash(...args) ?? after(await own.attachHash(...args)),
|
|
1402
|
+
attachSafeNonce: async (...args) => active()?.attachSafeNonce(...args) ??
|
|
1403
|
+
after(await own.attachSafeNonce(...args)),
|
|
1404
|
+
// A legacy hold already holds it: nothing is adopted.
|
|
1405
|
+
adopt: async (...args) => active()?.peek() ?? after(await own.adopt(...args)),
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* An exact-call handle. When its key holds a mirror of an intent's write,
|
|
1410
|
+
* it is that intent's hold: it never sends or releases on its own, and its
|
|
1411
|
+
* exit is the intent's. A mirror left after the intent ended is removed.
|
|
1412
|
+
*/
|
|
1413
|
+
function openExact(call, at) {
|
|
1414
|
+
const own = openOne(call, {}, at);
|
|
1415
|
+
const journal = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
|
|
1416
|
+
storage,
|
|
1417
|
+
locks: options.locks,
|
|
1418
|
+
at,
|
|
1419
|
+
});
|
|
1420
|
+
/** The intent this key mirrors, from storage alone. */
|
|
1421
|
+
const mirrored = () => {
|
|
1422
|
+
const record = journal.read();
|
|
1423
|
+
return record?.mirrorOf
|
|
1424
|
+
? { record, intent: openOne({ ...call, namespace: record.mirrorOf }) }
|
|
1425
|
+
: undefined;
|
|
1426
|
+
};
|
|
1427
|
+
/** Remove a mirror whose intent holds nothing any more. */
|
|
1428
|
+
async function clearStale(found) {
|
|
1429
|
+
await journal
|
|
1430
|
+
.withLock(async () => {
|
|
1431
|
+
const current = journal.read();
|
|
1432
|
+
if (current?.id === found.record.id)
|
|
1433
|
+
journal.clear(current);
|
|
1434
|
+
})
|
|
1435
|
+
.catch(() => {
|
|
1436
|
+
/* The stale mirror still holds; its intent is clear. */
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
/** The intent's state while it holds anything: this key is its mirror. */
|
|
1440
|
+
async function intentState(client) {
|
|
1441
|
+
const found = mirrored();
|
|
1442
|
+
if (!found)
|
|
1443
|
+
return null;
|
|
1444
|
+
const state = await found.intent.state(client);
|
|
1445
|
+
if (state.status !== "clear")
|
|
1446
|
+
return state;
|
|
1447
|
+
await clearStale(found);
|
|
1448
|
+
return null;
|
|
1449
|
+
}
|
|
1450
|
+
/** The intent in charge, from storage alone. */
|
|
1451
|
+
const route = () => {
|
|
1452
|
+
const found = mirrored();
|
|
1453
|
+
return found && found.intent.peek().status !== "clear"
|
|
1454
|
+
? found.intent
|
|
1455
|
+
: undefined;
|
|
1456
|
+
};
|
|
1457
|
+
return {
|
|
1458
|
+
...own,
|
|
1459
|
+
peek: () => route()?.peek() ?? own.peek(),
|
|
1460
|
+
async state(client, checks) {
|
|
1461
|
+
return (await intentState(client)) ?? own.state(client, checks);
|
|
1462
|
+
},
|
|
1463
|
+
async send(client, run) {
|
|
1464
|
+
const held = await intentState(client);
|
|
1465
|
+
if (held)
|
|
1466
|
+
throw new WriteHeldError(held);
|
|
1467
|
+
return own.send(client, run);
|
|
1468
|
+
},
|
|
1469
|
+
async exit(snapshot, acknowledgement, client) {
|
|
1470
|
+
const found = mirrored();
|
|
1471
|
+
if (!found || found.intent.peek().status === "clear")
|
|
1472
|
+
return own.exit(snapshot, acknowledgement, client);
|
|
1473
|
+
const ended = await found.intent.exit(snapshot, acknowledgement, client);
|
|
1474
|
+
if (ended.released)
|
|
1475
|
+
await clearStale(found);
|
|
1476
|
+
return ended;
|
|
1477
|
+
},
|
|
1478
|
+
onReceipt: (...args) => (route() ?? own).onReceipt(...args),
|
|
1479
|
+
onUnproven: (...args) => (route() ?? own).onUnproven(...args),
|
|
1480
|
+
onExecuted: (...args) => (route() ?? own).onExecuted(...args),
|
|
1481
|
+
attachHash: (...args) => (route() ?? own).attachHash(...args),
|
|
1482
|
+
attachSafeNonce: (...args) => (route() ?? own).attachSafeNonce(...args),
|
|
1483
|
+
// An intent's mirror already holds it: nothing is adopted.
|
|
1484
|
+
adopt: async (...args) => route()?.peek() ?? own.adopt(...args),
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
872
1487
|
return {
|
|
873
1488
|
open,
|
|
874
1489
|
/** Whether holds outlive this tab right now. */
|
|
@@ -876,14 +1491,7 @@ function createWriteHold(options = {}) {
|
|
|
876
1491
|
/** The review's line while holds last only in this tab, or null. */
|
|
877
1492
|
reviewLine: () => (storage.durable() ? null : messages_js_1.WRITE_HOLD_IN_TAB_LINE),
|
|
878
1493
|
/** Stop every settle poll and the tab listener (tests, teardown). */
|
|
879
|
-
dispose
|
|
880
|
-
for (const timer of timers.values())
|
|
881
|
-
clearTimeout(timer);
|
|
882
|
-
timers.clear();
|
|
883
|
-
if (onStorage)
|
|
884
|
-
globalThis.removeEventListener?.("storage", onStorage);
|
|
885
|
-
onStorage = undefined;
|
|
886
|
-
},
|
|
1494
|
+
dispose,
|
|
887
1495
|
};
|
|
888
1496
|
}
|
|
889
1497
|
//# sourceMappingURL=writeHold.js.map
|