@zerc20/wallet-sdk 0.0.0-stage → 0.2.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/LICENSE +24 -0
- package/README.md +26 -2
- package/dist/burnWorker.d.ts +1 -0
- package/dist/burnWorker.js +21 -0
- package/dist/burnWorker.js.map +1 -0
- package/dist/chain.d.ts +359 -0
- package/dist/chain.js +155 -0
- package/dist/chain.js.map +1 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +40 -0
- package/dist/context.js.map +1 -0
- package/dist/core.d.ts +43 -0
- package/dist/core.js +106 -0
- package/dist/core.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/network.d.ts +66 -0
- package/dist/network.js +55 -0
- package/dist/network.js.map +1 -0
- package/dist/pos.d.ts +93 -0
- package/dist/pos.js +302 -0
- package/dist/pos.js.map +1 -0
- package/dist/prove.d.ts +13 -0
- package/dist/prove.js +53 -0
- package/dist/prove.js.map +1 -0
- package/dist/rageExit.d.ts +5 -0
- package/dist/rageExit.js +146 -0
- package/dist/rageExit.js.map +1 -0
- package/dist/scan.d.ts +17 -0
- package/dist/scan.js +47 -0
- package/dist/scan.js.map +1 -0
- package/dist/seed.d.ts +8 -0
- package/dist/seed.js +35 -0
- package/dist/seed.js.map +1 -0
- package/dist/servers.d.ts +60 -0
- package/dist/servers.js +117 -0
- package/dist/servers.js.map +1 -0
- package/dist/services.d.ts +62 -0
- package/dist/services.js +131 -0
- package/dist/services.js.map +1 -0
- package/dist/spend.d.ts +151 -0
- package/dist/spend.js +745 -0
- package/dist/spend.js.map +1 -0
- package/dist/state.d.ts +174 -0
- package/dist/state.js +45 -0
- package/dist/state.js.map +1 -0
- package/dist/sync.d.ts +84 -0
- package/dist/sync.js +615 -0
- package/dist/sync.js.map +1 -0
- package/dist/tree.d.ts +19 -0
- package/dist/tree.js +63 -0
- package/dist/tree.js.map +1 -0
- package/dist/wallet.d.ts +303 -0
- package/dist/wallet.js +1002 -0
- package/dist/wallet.js.map +1 -0
- package/package.json +60 -4
- package/src/burnWorker.ts +25 -0
- package/src/chain.ts +228 -0
- package/src/context.ts +84 -0
- package/src/core.ts +147 -0
- package/src/errors.ts +63 -0
- package/src/index.ts +41 -0
- package/src/network.ts +94 -0
- package/src/pos.ts +415 -0
- package/src/prove.ts +67 -0
- package/src/rageExit.ts +168 -0
- package/src/scan.ts +62 -0
- package/src/seed.ts +46 -0
- package/src/servers.ts +172 -0
- package/src/services.ts +195 -0
- package/src/snarkjs.d.ts +11 -0
- package/src/spend.ts +912 -0
- package/src/state.ts +211 -0
- package/src/sync.ts +698 -0
- package/src/tree.ts +108 -0
- package/src/wallet.ts +1316 -0
- package/wasm/zerc20_wallet_core.d.ts +176 -0
- package/wasm/zerc20_wallet_core.js +783 -0
- package/wasm/zerc20_wallet_core_bg.wasm +0 -0
- package/wasm/zerc20_wallet_core_bg.wasm.d.ts +32 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// One pass of keeping up: new notes from storage, then per chain the wallet's tags and nullifiers among the
|
|
2
|
+
// indexer's new entries. A wallet only ever looks for what it already expects; see scan.ts.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type Deposit,
|
|
6
|
+
type LaneRecord,
|
|
7
|
+
type NewRecord,
|
|
8
|
+
type Note,
|
|
9
|
+
type OwnReceipt,
|
|
10
|
+
type Receipt,
|
|
11
|
+
type SyncState,
|
|
12
|
+
commit,
|
|
13
|
+
emptyState,
|
|
14
|
+
planBurnBlock,
|
|
15
|
+
planReceipt,
|
|
16
|
+
refresh,
|
|
17
|
+
} from '@zerc20/storage-sdk';
|
|
18
|
+
import { type Hex, bytesToHex, hexToBytes, numberToHex, parseAbiItem } from 'viem';
|
|
19
|
+
import type { Chain } from './chain.js';
|
|
20
|
+
import { type ChainCtx, type Ctx, chainCtx, chainState, spendKey } from './context.js';
|
|
21
|
+
import { burnAddress, core } from './core.js';
|
|
22
|
+
import { type Hit, type Watch, scan } from './scan.js';
|
|
23
|
+
import { type Asp, type AspStatus, type Indexer, PAGE } from './servers.js';
|
|
24
|
+
import type { DepositRecord, NoteReceipt, NoteRecord, Sample } from './state.js';
|
|
25
|
+
|
|
26
|
+
/** The ledger's log of each commitment, found by its index (`contracts/src/CommitmentLedger.sol`). */
|
|
27
|
+
const committedEvent = parseAbiItem('event Committed(uint64 indexed index, uint256[4] fields)');
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 86_400_000;
|
|
30
|
+
/** Storage is read in full this often, in case a replica left entries out of its log. */
|
|
31
|
+
const FULL_REFRESH_MS = 3_600_000;
|
|
32
|
+
// `field0`'s high 32 bits (contracts/src/libs/Protocol.sol)
|
|
33
|
+
const NOTE_TAG = 0x789f26ebn;
|
|
34
|
+
const TRANSFER_TAG = 0xddf252adn;
|
|
35
|
+
/** Look at entries past the final count only this long after something was started or announced. */
|
|
36
|
+
export const WAIT_MS = 30 * 60_000;
|
|
37
|
+
|
|
38
|
+
const hex32 = (b: Uint8Array): Hex => bytesToHex(b);
|
|
39
|
+
const big = (b: Uint8Array): bigint => BigInt(hex32(b));
|
|
40
|
+
|
|
41
|
+
/** The final commitment count when the indexer last reported at or before `atMs`, so nothing announced after it
|
|
42
|
+
* can be committed before it. 0 without such a sample (a restore reads from the birthday). */
|
|
43
|
+
export function commitmentsAt(samples: Sample[], atMs: number): number {
|
|
44
|
+
let best = 0;
|
|
45
|
+
for (const s of samples) if (s.atMs <= atMs) best = Math.max(best, s.commitments);
|
|
46
|
+
return best;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A note's hash under the key that owns it (the wallet's, or with `keyIndex` a receive code's), and the watch for its
|
|
50
|
+
* tag. */
|
|
51
|
+
function watchFor(
|
|
52
|
+
ctx: Ctx,
|
|
53
|
+
chainId: bigint,
|
|
54
|
+
tokenId: number,
|
|
55
|
+
value: bigint,
|
|
56
|
+
salt: Hex,
|
|
57
|
+
keyIndex: number | undefined,
|
|
58
|
+
from: number,
|
|
59
|
+
) {
|
|
60
|
+
const { publicKey } = spendKey(ctx, keyIndex === undefined ? undefined : { chainId, tokenId, index: keyIndex });
|
|
61
|
+
const noteHash = core.noteHash(chainId, publicKey, `0x${value.toString(16)}`, tokenId, salt) as Hex;
|
|
62
|
+
return { noteHash, watch: { key: numberToHex(core.noteTag(tokenId, noteHash), { size: 4 }), from, scannedTo: from } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The storage state, empty until its first refresh. It watches every chain of the network, also one the network
|
|
66
|
+
* gained after the state was saved: the next refresh reads that one's lanes from slot 0. */
|
|
67
|
+
export function storageState(ctx: Ctx): SyncState {
|
|
68
|
+
const chains = ctx.chains.map((c) => c.config.chainId);
|
|
69
|
+
const state = ctx.state.storage ?? emptyState(ctx.view, chains);
|
|
70
|
+
const gained = chains.filter((c) => !state.chains.includes(c));
|
|
71
|
+
return (ctx.state.storage = gained.length > 0 ? { ...state, chains: [...state.chains, ...gained] } : state);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function syncStorage(ctx: Ctx, tokens: Map<bigint, number[]>): Promise<void> {
|
|
75
|
+
const full = Date.now() - ctx.lastFullRefreshMs > FULL_REFRESH_MS;
|
|
76
|
+
takeRefresh(ctx, await refresh(ctx.storage, ctx.view, storageState(ctx), { full }), tokens);
|
|
77
|
+
if (full) ctx.lastFullRefreshMs = Date.now();
|
|
78
|
+
await writeReceipts(ctx, tokens);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Writes each receipt storage does not hold yet to the own lane, one a batch: `planReceipt` takes the lane's next
|
|
82
|
+
* slot, which a second one in the batch would take too. The refresh that follows brings it back as written. */
|
|
83
|
+
async function writeReceipts(ctx: Ctx, tokens: Map<bigint, number[]>): Promise<void> {
|
|
84
|
+
for (const r of ctx.state.receipts.filter((r) => r.unwritten)) {
|
|
85
|
+
const head = { chainId: r.chainId, tokenId: r.tokenId, burnAddress: hexToBytes(r.burnAddress) };
|
|
86
|
+
const receipt: OwnReceipt =
|
|
87
|
+
r.kind === 'returned'
|
|
88
|
+
? {
|
|
89
|
+
...head,
|
|
90
|
+
kind: r.kind,
|
|
91
|
+
index: r.index,
|
|
92
|
+
recipient: hexToBytes(r.recipient),
|
|
93
|
+
amount: hexToBytes(numberToHex(r.amount, { size: 32 })),
|
|
94
|
+
txHash: hexToBytes(r.txHash),
|
|
95
|
+
}
|
|
96
|
+
: { ...head, kind: r.kind, depositAddress: hexToBytes(r.depositAddress) };
|
|
97
|
+
takeRefresh(
|
|
98
|
+
ctx,
|
|
99
|
+
await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planReceipt(ctx.view, s, receipt)),
|
|
100
|
+
tokens,
|
|
101
|
+
);
|
|
102
|
+
delete r.unwritten;
|
|
103
|
+
await ctx.save();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A storage receipt as the wallet keeps it. */
|
|
108
|
+
export const noteReceipt = (r: Receipt): NoteReceipt => ({
|
|
109
|
+
kind: r.kind,
|
|
110
|
+
amount: big(r.amount),
|
|
111
|
+
fee: big(r.fee),
|
|
112
|
+
counterparty: bytesToHex(r.counterparty),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** Each chain's token ids. */
|
|
116
|
+
export async function tokenIds(ctx: Ctx): Promise<Map<bigint, number[]>> {
|
|
117
|
+
const tokens = new Map<bigint, number[]>();
|
|
118
|
+
await Promise.all(
|
|
119
|
+
ctx.chains.map(async (c) =>
|
|
120
|
+
tokens.set(
|
|
121
|
+
c.config.chainId,
|
|
122
|
+
(await c.chain.tokens()).map((t) => t.tokenId),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
return tokens;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Takes a storage refresh: its state, plus what it found for this wallet: notes (to its address, its receive codes
|
|
130
|
+
* and itself), deposits payers told, burn blocks recorded, deposits it paid others, and its receipts. A record is
|
|
131
|
+
* announced by one refresh only, so `commit`'s refresh must come through here too: a payment to oneself, or one that
|
|
132
|
+
* arrived just before, would otherwise be lost. */
|
|
133
|
+
export function takeRefresh(
|
|
134
|
+
ctx: Ctx,
|
|
135
|
+
{ state, added }: { state: SyncState; added: NewRecord[] },
|
|
136
|
+
tokens: Map<bigint, number[]>,
|
|
137
|
+
): void {
|
|
138
|
+
// the wallet keeps its own records: storage's copies would only double the saved state
|
|
139
|
+
for (const lane of [...state.incoming, ...state.outgoing.lanes, { chains: state.own }]) {
|
|
140
|
+
for (const chain of lane.chains) chain.records = [];
|
|
141
|
+
}
|
|
142
|
+
ctx.state.storage = state;
|
|
143
|
+
for (const a of added) {
|
|
144
|
+
const r = a.record;
|
|
145
|
+
// what it paid others: a note's receipt rides on its change; a deposit is watched for here
|
|
146
|
+
if (a.direction === 'outgoing') {
|
|
147
|
+
if (r.kind === 'deposit') takeSent(ctx, r, a.peer!, tokens);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (r.kind === 'deposit') takeDeposit(ctx, r, a.peer!, tokens);
|
|
151
|
+
else if (r.kind === 'burn-block') takeBurnBlock(ctx, r, a.lane?.via.type === 'pos', tokens);
|
|
152
|
+
else if (r.kind === 'note') upsertNote(ctx, a, r, tokens);
|
|
153
|
+
else if (r.kind !== 'pos-lane') takeReceipt(ctx, r); // a point of sale's lane is read by the storage refresh
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A deposit this wallet paid into someone's burn address, as storage told it with the address: watched for its
|
|
158
|
+
* transfer, from a day before storage held it, to show once it landed. */
|
|
159
|
+
function takeSent(ctx: Ctx, d: Deposit, to: Uint8Array, tokens: Map<bigint, number[]>): void {
|
|
160
|
+
const { chainId, tokenId } = d;
|
|
161
|
+
if (!tokens.get(chainId)?.includes(tokenId)) return;
|
|
162
|
+
const address = bytesToHex(d.burnAddress);
|
|
163
|
+
if (ctx.state.sent.some((s) => s.chainId === chainId && s.address === address)) return;
|
|
164
|
+
const atMs = Number(d.atNs / 1_000_000n);
|
|
165
|
+
const from = commitmentsAt(chainState(ctx, chainId).samples, atMs - DAY_MS);
|
|
166
|
+
const watch = { key: numberToHex(core.transferTag(tokenId, address), { size: 4 }), from, scannedTo: from };
|
|
167
|
+
ctx.state.sent.push({
|
|
168
|
+
chainId,
|
|
169
|
+
tokenId,
|
|
170
|
+
address,
|
|
171
|
+
to: bytesToHex(to),
|
|
172
|
+
createdMs: atMs,
|
|
173
|
+
watches: [{ tokenId, watch }],
|
|
174
|
+
found: [],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A receipt from the own lane: one this device wrote, now held by storage (dated as storage holds it, alike on every
|
|
179
|
+
* device), or another device's. */
|
|
180
|
+
function takeReceipt(ctx: Ctx, r: OwnReceipt & { atNs: bigint }): void {
|
|
181
|
+
const burnAddress = bytesToHex(r.burnAddress);
|
|
182
|
+
const atMs = Number(r.atNs / 1_000_000n);
|
|
183
|
+
const same = ctx.state.receipts.find(
|
|
184
|
+
(x) =>
|
|
185
|
+
x.chainId === r.chainId &&
|
|
186
|
+
x.kind === r.kind &&
|
|
187
|
+
x.burnAddress === burnAddress &&
|
|
188
|
+
(x.kind !== 'returned' || (r.kind === 'returned' && x.index === r.index)),
|
|
189
|
+
);
|
|
190
|
+
if (same) {
|
|
191
|
+
delete same.unwritten;
|
|
192
|
+
same.atMs = atMs;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const head = { chainId: r.chainId, tokenId: r.tokenId, burnAddress, atMs };
|
|
196
|
+
ctx.state.receipts.push(
|
|
197
|
+
r.kind === 'returned'
|
|
198
|
+
? {
|
|
199
|
+
...head,
|
|
200
|
+
kind: r.kind,
|
|
201
|
+
index: r.index,
|
|
202
|
+
recipient: bytesToHex(r.recipient),
|
|
203
|
+
amount: big(r.amount),
|
|
204
|
+
txHash: bytesToHex(r.txHash),
|
|
205
|
+
}
|
|
206
|
+
: { ...head, kind: r.kind, depositAddress: bytesToHex(r.depositAddress) },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A burn address of this wallet's that a payer chose and told in storage, watched as a deposit of its own once its
|
|
211
|
+
* salt checks out under the wallet's key: one hash. */
|
|
212
|
+
export function takeDeposit(ctx: Ctx, deposit: Deposit, payer: Uint8Array, tokens: Map<bigint, number[]>): void {
|
|
213
|
+
const { chainId, tokenId } = deposit;
|
|
214
|
+
if (!tokens.get(chainId)?.includes(tokenId)) return;
|
|
215
|
+
const salt = hex32(deposit.burnSalt);
|
|
216
|
+
if (ctx.state.deposits.some((d) => d.chainId === chainId && d.salt === salt)) return;
|
|
217
|
+
const address = core.burnAddressOf(chainId, spendKey(ctx).publicKey, salt) as Hex | undefined;
|
|
218
|
+
if (!address) return;
|
|
219
|
+
const atMs = Number(deposit.atNs / 1_000_000n);
|
|
220
|
+
// storage held it before the payer's transfer; a day of margin covers clock skew
|
|
221
|
+
const from = commitmentsAt(chainState(ctx, chainId).samples, atMs - DAY_MS);
|
|
222
|
+
ctx.state.deposits.push({
|
|
223
|
+
chainId,
|
|
224
|
+
tokenId,
|
|
225
|
+
burnIndex: -1,
|
|
226
|
+
salt,
|
|
227
|
+
address,
|
|
228
|
+
createdMs: atMs,
|
|
229
|
+
depositAddress: null,
|
|
230
|
+
payer: hex32(payer),
|
|
231
|
+
watches: [
|
|
232
|
+
{ tokenId, watch: { key: numberToHex(core.transferTag(tokenId, address), { size: 4 }), from, scannedTo: from } },
|
|
233
|
+
],
|
|
234
|
+
found: [],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Burn addresses recorded in storage, by salt: each checked to be this wallet's own, which takes one hash, and watched
|
|
239
|
+
* from a day before it was recorded. A point of sale's (`pos`, from its lane) are its payment addresses, with no burn
|
|
240
|
+
* index of this wallet's. This wallet's own, from another device or from before a restore, are taken as handed out:
|
|
241
|
+
* none is handed out again. */
|
|
242
|
+
function takeBurnBlock(
|
|
243
|
+
ctx: Ctx,
|
|
244
|
+
b: Extract<LaneRecord, { kind: 'burn-block' }>,
|
|
245
|
+
pos: boolean,
|
|
246
|
+
tokens: Map<bigint, number[]>,
|
|
247
|
+
): void {
|
|
248
|
+
if (!tokens.has(b.chainId)) return;
|
|
249
|
+
const { publicKey } = spendKey(ctx);
|
|
250
|
+
const atMs = Number(b.atNs / 1_000_000n);
|
|
251
|
+
const from = commitmentsAt(chainState(ctx, b.chainId).samples, atMs - DAY_MS);
|
|
252
|
+
b.salts.forEach((bytes, i) => {
|
|
253
|
+
const salt = hex32(bytes);
|
|
254
|
+
const address = core.burnAddressOf(b.chainId, publicKey, salt) as Hex | undefined;
|
|
255
|
+
if (!address || ctx.state.deposits.some((d) => d.chainId === b.chainId && d.salt === salt)) return;
|
|
256
|
+
if (!pos) {
|
|
257
|
+
const next = chainState(ctx, b.chainId).burnNext;
|
|
258
|
+
next[b.tokenId] = Math.max(next[b.tokenId] ?? 0, b.first + i + 1);
|
|
259
|
+
}
|
|
260
|
+
const watch = { key: numberToHex(core.transferTag(b.tokenId, address), { size: 4 }), from, scannedTo: from };
|
|
261
|
+
const record: DepositRecord = {
|
|
262
|
+
chainId: b.chainId,
|
|
263
|
+
tokenId: b.tokenId,
|
|
264
|
+
burnIndex: pos ? -1 : b.first + i,
|
|
265
|
+
salt,
|
|
266
|
+
address,
|
|
267
|
+
createdMs: atMs,
|
|
268
|
+
depositAddress: null,
|
|
269
|
+
watches: [{ tokenId: b.tokenId, watch }],
|
|
270
|
+
found: [],
|
|
271
|
+
};
|
|
272
|
+
if (pos) record.managed = true;
|
|
273
|
+
ctx.state.deposits.push(record);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** This wallet's lowest recorded burn address of a token that it has not handed out, if any. */
|
|
278
|
+
export function unusedBurn(ctx: Ctx, chainId: bigint, tokenId: number): DepositRecord | undefined {
|
|
279
|
+
const next = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
|
|
280
|
+
let best: DepositRecord | undefined;
|
|
281
|
+
for (const d of ctx.state.deposits) {
|
|
282
|
+
if (d.chainId !== chainId || d.tokenId !== tokenId || d.managed || d.payer || d.burnIndex < next) continue;
|
|
283
|
+
if (!best || d.burnIndex < best.burnIndex) best = d;
|
|
284
|
+
}
|
|
285
|
+
return best;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Records this wallet's next burn address of a token in storage (a burn block of one), then watches it. Every device
|
|
289
|
+
* of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes. */
|
|
290
|
+
export async function recordBurn(
|
|
291
|
+
ctx: Ctx,
|
|
292
|
+
chainId: bigint,
|
|
293
|
+
tokenId: number,
|
|
294
|
+
tokens: Map<bigint, number[]>,
|
|
295
|
+
signal?: AbortSignal,
|
|
296
|
+
): Promise<DepositRecord> {
|
|
297
|
+
let index = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
|
|
298
|
+
for (const d of ctx.state.deposits) {
|
|
299
|
+
if (d.chainId === chainId && d.tokenId === tokenId && !d.managed) index = Math.max(index, d.burnIndex + 1);
|
|
300
|
+
}
|
|
301
|
+
const { salt, address } = await burnAddress(ctx.seed, chainId, tokenId, index, signal);
|
|
302
|
+
const { commitmentsFinal: from } = await chainCtx(ctx, chainId).indexer.status();
|
|
303
|
+
const block = { chainId, tokenId, first: index, salts: [hexToBytes(salt)] };
|
|
304
|
+
const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planBurnBlock(ctx.view, s, block));
|
|
305
|
+
const record: DepositRecord = {
|
|
306
|
+
chainId,
|
|
307
|
+
tokenId,
|
|
308
|
+
burnIndex: index,
|
|
309
|
+
salt,
|
|
310
|
+
address,
|
|
311
|
+
createdMs: Date.now(),
|
|
312
|
+
depositAddress: null,
|
|
313
|
+
// watched under this token right away, and under every other token from the next sync on
|
|
314
|
+
watches: [
|
|
315
|
+
{ tokenId, watch: { key: numberToHex(core.transferTag(tokenId, address), { size: 4 }), from, scannedTo: from } },
|
|
316
|
+
],
|
|
317
|
+
found: [],
|
|
318
|
+
};
|
|
319
|
+
// before the refresh, which brings the block back: taken as new, it would count as handed out
|
|
320
|
+
ctx.state.deposits.push(record);
|
|
321
|
+
takeRefresh(ctx, result, tokens);
|
|
322
|
+
await ctx.save();
|
|
323
|
+
return record;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Keeps one burn address recorded ahead for each token this wallet has handed one out for, so that handing it out
|
|
327
|
+
* writes nothing: a write then would mark the transfer that follows as this wallet's. A token's first one is recorded
|
|
328
|
+
* as it is handed out. Reads the tokens only when one is due. */
|
|
329
|
+
export async function topUpBurns(ctx: Ctx, signal?: AbortSignal): Promise<void> {
|
|
330
|
+
const onNetwork = new Set(ctx.chains.map((c) => c.config.chainId));
|
|
331
|
+
const used = new Map(
|
|
332
|
+
ctx.state.deposits
|
|
333
|
+
.filter((d) => !d.managed && onNetwork.has(d.chainId))
|
|
334
|
+
.map((d) => [`${d.chainId}:${d.tokenId}`, d]),
|
|
335
|
+
);
|
|
336
|
+
let tokens: Map<bigint, number[]> | undefined;
|
|
337
|
+
for (const { chainId, tokenId } of used.values()) {
|
|
338
|
+
if (!unusedBurn(ctx, chainId, tokenId))
|
|
339
|
+
await recordBurn(ctx, chainId, tokenId, (tokens ??= await tokenIds(ctx)), signal);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function upsertNote(ctx: Ctx, a: NewRecord, note: Note, tokens: Map<bigint, number[]>): void {
|
|
344
|
+
const { chainId, tokenId, value, noteSalt } = note.opening;
|
|
345
|
+
if (!tokens.has(chainId)) return;
|
|
346
|
+
const salt = hex32(noteSalt);
|
|
347
|
+
const peer = a.peer && hex32(a.peer);
|
|
348
|
+
const { atNs } = note;
|
|
349
|
+
const existing = ctx.state.notes.find((n) => n.chainId === chainId && n.salt === salt);
|
|
350
|
+
const receipt = note.receipt && noteReceipt(note.receipt);
|
|
351
|
+
if (existing) {
|
|
352
|
+
// recorded before storage announced it (a payment's change): keep what was found
|
|
353
|
+
Object.assign(existing, { peer, atNs }, receipt && { receipt });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// a payment to one of this wallet's codes is owned by the code's key, and is in the code's token
|
|
357
|
+
const via = a.lane?.via;
|
|
358
|
+
const code = via?.type === 'code' && 'index' in via ? via : null;
|
|
359
|
+
if (code && code.tokenId !== tokenId) return;
|
|
360
|
+
const keyIndex = code?.index;
|
|
361
|
+
// storage first held it before the payer committed it on chain; a day of margin covers clock skew
|
|
362
|
+
const from = commitmentsAt(chainState(ctx, chainId).samples, Number(atNs / 1_000_000n) - DAY_MS);
|
|
363
|
+
ctx.state.notes.push({
|
|
364
|
+
chainId,
|
|
365
|
+
tokenId,
|
|
366
|
+
value: big(value),
|
|
367
|
+
salt,
|
|
368
|
+
source: a.direction === 'own' ? 'self' : 'received',
|
|
369
|
+
...(receipt && { receipt }),
|
|
370
|
+
peer,
|
|
371
|
+
atNs,
|
|
372
|
+
...(keyIndex === undefined ? {} : { keyIndex }),
|
|
373
|
+
...watchFor(ctx, chainId, tokenId, big(value), salt, keyIndex, from),
|
|
374
|
+
found: [],
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Whether something was started or announced recently enough to look past the final count for it. */
|
|
379
|
+
function waiting(ctx: Ctx, chainId: bigint, now: number): boolean {
|
|
380
|
+
const recent = (ms: number | null) => ms !== null && now - ms < WAIT_MS;
|
|
381
|
+
return (
|
|
382
|
+
ctx.state.ops.some((o) => o.chainId === chainId && o.status === 'pending' && recent(o.createdMs)) ||
|
|
383
|
+
[...ctx.state.deposits, ...ctx.state.sent].some(
|
|
384
|
+
(d) => d.chainId === chainId && d.found.length === 0 && recent(d.createdMs),
|
|
385
|
+
) ||
|
|
386
|
+
ctx.state.notes.some(
|
|
387
|
+
(n) => n.chainId === chainId && n.found.length === 0 && n.atNs !== null && recent(Number(n.atNs / 1_000_000n)),
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** One chain: new tags, deposit details, then nullifiers. */
|
|
393
|
+
async function syncChain(ctx: Ctx, c: ChainCtx, tokens: number[]): Promise<void> {
|
|
394
|
+
const chainId = c.config.chainId;
|
|
395
|
+
const s = chainState(ctx, chainId);
|
|
396
|
+
const status = await c.indexer.status();
|
|
397
|
+
const now = Date.now();
|
|
398
|
+
const last = s.samples.at(-1);
|
|
399
|
+
if (!last || status.commitmentsFinal >= last.commitments + PAGE) {
|
|
400
|
+
s.samples.push({ atMs: now, commitments: status.commitmentsFinal, nullifiers: status.nullifiersFinal });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// every burn address is watched under every token, new tokens included
|
|
404
|
+
for (const d of ctx.state.deposits.filter((d) => d.chainId === chainId)) {
|
|
405
|
+
for (const tokenId of tokens) {
|
|
406
|
+
if (d.watches.some((w) => w.tokenId === tokenId)) continue;
|
|
407
|
+
const from = d.watches[0].watch.from;
|
|
408
|
+
d.watches.push({
|
|
409
|
+
tokenId,
|
|
410
|
+
watch: { key: numberToHex(core.transferTag(tokenId, d.address), { size: 4 }), from, scannedTo: from },
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// tags: notes, deposits, and deposits paid to others
|
|
416
|
+
const notes = ctx.state.notes.filter((n) => n.chainId === chainId);
|
|
417
|
+
const deposits = ctx.state.deposits.filter((d) => d.chainId === chainId);
|
|
418
|
+
const sent = ctx.state.sent.filter((s) => s.chainId === chainId);
|
|
419
|
+
const transfers = [...deposits, ...sent];
|
|
420
|
+
const watches: Watch[] = [
|
|
421
|
+
...notes.flatMap((n) => (n.watch ? [n.watch] : [])),
|
|
422
|
+
...transfers.flatMap((d) => d.watches.map((w) => w.watch)),
|
|
423
|
+
];
|
|
424
|
+
const live = waiting(ctx, chainId, now) ? status.commitments : status.commitmentsFinal;
|
|
425
|
+
const hits = await scan(watches, 4, (p) => c.indexer.page('tags', p), status.commitmentsFinal, live);
|
|
426
|
+
applyHits(notes, transfers, hits);
|
|
427
|
+
dropReorged([...notes, ...transfers], status.commitmentsFinal);
|
|
428
|
+
|
|
429
|
+
const fieldsAt = commitmentFields(c.indexer);
|
|
430
|
+
// a note is counted once its commitment is the note it expects: a 4-byte tag matches someone else's now and then.
|
|
431
|
+
// Read as soon as it is seen, so that it shows; read again once it is final (`place` clears it)
|
|
432
|
+
for (const n of notes) {
|
|
433
|
+
for (const f of n.found.filter((f) => !f.confirmed)) {
|
|
434
|
+
const fields = await fieldsAt(f.index);
|
|
435
|
+
if (!fields) continue;
|
|
436
|
+
const ours =
|
|
437
|
+
fields[0] === ((NOTE_TAG << 32n) | BigInt(n.tokenId)) &&
|
|
438
|
+
fields[1] === BigInt(n.noteHash) &&
|
|
439
|
+
fields[2] === 0n &&
|
|
440
|
+
fields[3] === 0n;
|
|
441
|
+
if (ours) f.confirmed = true;
|
|
442
|
+
else n.found.splice(n.found.indexOf(f), 1);
|
|
443
|
+
}
|
|
444
|
+
// a note's hash is unique (its payer chose a fresh salt): once confirmed where no reorg moves it, stop watching for it
|
|
445
|
+
if (n.found.some((f) => f.confirmed && !f.tentative)) n.watch = null;
|
|
446
|
+
}
|
|
447
|
+
await readTransfers(fieldsAt, transfers);
|
|
448
|
+
const asp = await readDecisions(c.asp, deposits);
|
|
449
|
+
// only a hint for the app: it holds back nothing
|
|
450
|
+
if (asp) await estimateDecisions(c, status.finalBlock, asp.delaySecs, deposits).catch(() => {});
|
|
451
|
+
// each one's block time, read with the page of commitments just read, so it tells the indexer nothing more
|
|
452
|
+
const timeAt = commitmentTimes(c.indexer);
|
|
453
|
+
for (const f of [...notes.flatMap((r) => r.found), ...transfers.flatMap((r) => r.found)])
|
|
454
|
+
f.atMs ??= (await timeAt(f.index)) ?? undefined;
|
|
455
|
+
|
|
456
|
+
// nullifiers of everything final and unspent
|
|
457
|
+
const marks = new Map<number, Uint8Array>();
|
|
458
|
+
const nullifierFrom = async (index: number): Promise<number> => {
|
|
459
|
+
const commitmentPage = Math.floor(index / PAGE);
|
|
460
|
+
const p = Math.floor(commitmentPage / PAGE);
|
|
461
|
+
if (!marks.has(p)) marks.set(p, await c.indexer.page('marks', p));
|
|
462
|
+
const at = (commitmentPage % PAGE) * 16 + 8;
|
|
463
|
+
const page = marks.get(p)!;
|
|
464
|
+
return at + 8 <= page.length ? Number(new DataView(page.buffer, page.byteOffset).getBigUint64(at, false)) : 0;
|
|
465
|
+
};
|
|
466
|
+
// a zero-value change is never spent: it carries its payment's receipt
|
|
467
|
+
const owned = [
|
|
468
|
+
...notes
|
|
469
|
+
.filter((n) => n.value > 0n)
|
|
470
|
+
.flatMap((n) =>
|
|
471
|
+
n.found.map((f) => ({
|
|
472
|
+
f,
|
|
473
|
+
code: n.keyIndex === undefined ? undefined : { chainId, tokenId: n.tokenId, index: n.keyIndex },
|
|
474
|
+
})),
|
|
475
|
+
),
|
|
476
|
+
...deposits.flatMap((d) => d.found.map((f) => ({ f, code: undefined }))),
|
|
477
|
+
].filter(({ f }) => !f.tentative && !f.spent);
|
|
478
|
+
for (const { f, code } of owned) {
|
|
479
|
+
if (f.nullifier) continue;
|
|
480
|
+
const nullifier = core.nullifier(chainId, BigInt(f.index), spendKey(ctx, code).nullifyingKey);
|
|
481
|
+
const from = await nullifierFrom(f.index);
|
|
482
|
+
f.nullifier = { key: nullifier.slice(0, 18) as Hex, from, scannedTo: from };
|
|
483
|
+
}
|
|
484
|
+
const nullifierWatches = owned.map(({ f }) => f.nullifier!);
|
|
485
|
+
const spent = await scan(
|
|
486
|
+
nullifierWatches,
|
|
487
|
+
8,
|
|
488
|
+
(p) => c.indexer.page('nullifiers', p),
|
|
489
|
+
status.nullifiersFinal,
|
|
490
|
+
live === status.commitments ? status.nullifiers : status.nullifiersFinal,
|
|
491
|
+
);
|
|
492
|
+
const byNullifier = new Map(owned.map(({ f }) => [f.nullifier!.key, f]));
|
|
493
|
+
for (const hit of spent) {
|
|
494
|
+
const f = byNullifier.get(hit.key);
|
|
495
|
+
if (f && !hit.tentative) f.spent = true;
|
|
496
|
+
}
|
|
497
|
+
// a spent position needs no more watching
|
|
498
|
+
for (const r of [...notes, ...deposits]) for (const f of r.found) if (f.spent) delete f.nullifier;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The four fields of the commitment at an index, read from its page of commitments, each page once per reader: `null`
|
|
502
|
+
* until the page is served that far. */
|
|
503
|
+
export function commitmentFields(indexer: Indexer): (index: number) => Promise<bigint[] | null> {
|
|
504
|
+
const pages = new Map<number, Promise<Uint8Array>>();
|
|
505
|
+
return async (index) => {
|
|
506
|
+
const chunk = Math.floor(index / PAGE);
|
|
507
|
+
let page = pages.get(chunk);
|
|
508
|
+
if (!page) pages.set(chunk, (page = indexer.commitments(chunk).then((p) => p.entries)));
|
|
509
|
+
const entries = await page;
|
|
510
|
+
const at = (index % PAGE) * 128;
|
|
511
|
+
if (entries.length < at + 128) return null; // not served yet
|
|
512
|
+
return [0, 1, 2, 3].map((f) => BigInt(bytesToHex(entries.subarray(at + 32 * f, at + 32 * (f + 1)))));
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The block time (ms) of the commitment at an index, from its page of times, each page once per reader: `null` until
|
|
517
|
+
* the page is served that far, or when it cannot be read. */
|
|
518
|
+
export function commitmentTimes(indexer: Indexer): (index: number) => Promise<number | null> {
|
|
519
|
+
const pages = new Map<number, Promise<Uint8Array | null>>();
|
|
520
|
+
return async (index) => {
|
|
521
|
+
const p = Math.floor(index / PAGE);
|
|
522
|
+
let page = pages.get(p);
|
|
523
|
+
if (!page) pages.set(p, (page = indexer.page('times', p).catch(() => null)));
|
|
524
|
+
const bytes = await page;
|
|
525
|
+
const at = (index % PAGE) * 8;
|
|
526
|
+
if (!bytes || bytes.length < at + 8) return null;
|
|
527
|
+
return Number(new DataView(bytes.buffer, bytes.byteOffset).getBigUint64(at, false)) * 1000;
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Burn addresses and what was found paid to them: a wallet's deposits, or a point of sale's sales. */
|
|
532
|
+
type Watched = Pick<DepositRecord, 'address' | 'watches' | 'found'>;
|
|
533
|
+
|
|
534
|
+
/** The transaction that committed `index`: the ledger's log of it, in the block the indexer names. Null while the
|
|
535
|
+
* indexer does not serve that block yet; its hash null while the chain's RPC does not show the log. */
|
|
536
|
+
export async function commitmentTx(
|
|
537
|
+
chain: Chain,
|
|
538
|
+
indexer: Indexer,
|
|
539
|
+
index: number,
|
|
540
|
+
): Promise<{ hash: Hex | null; block: bigint } | null> {
|
|
541
|
+
const blocks = await indexer.page('blocks', Math.floor(index / PAGE));
|
|
542
|
+
const at = (index % PAGE) * 8;
|
|
543
|
+
if (blocks.length < at + 8) return null;
|
|
544
|
+
const block = BigInt(bytesToHex(blocks.subarray(at, at + 8)));
|
|
545
|
+
const [log] = await chain.client.getLogs({
|
|
546
|
+
address: await chain.ledger(),
|
|
547
|
+
event: committedEvent,
|
|
548
|
+
args: { index: BigInt(index) },
|
|
549
|
+
fromBlock: block,
|
|
550
|
+
toBlock: block,
|
|
551
|
+
});
|
|
552
|
+
return { hash: log?.transactionHash ?? null, block };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Each deposit's sender and amount, read as soon as it is seen, so that it shows; read again once it is final (`place`
|
|
556
|
+
* clears them), since a reorg may still move what sits at a position until then. A tag collision is dropped. */
|
|
557
|
+
export async function readTransfers(
|
|
558
|
+
fieldsAt: (index: number) => Promise<bigint[] | null>,
|
|
559
|
+
deposits: Watched[],
|
|
560
|
+
): Promise<void> {
|
|
561
|
+
for (const d of deposits) {
|
|
562
|
+
for (const f of d.found.filter((f) => f.amount === null)) {
|
|
563
|
+
const fields = await fieldsAt(f.index);
|
|
564
|
+
if (!fields) continue;
|
|
565
|
+
if (fields[0] !== ((TRANSFER_TAG << 32n) | BigInt(f.tokenId)) || fields[2] !== BigInt(d.address)) {
|
|
566
|
+
d.found.splice(d.found.indexOf(f), 1);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
f.sender = numberToHex(fields[1], { size: 20 });
|
|
570
|
+
f.amount = fields[3]!;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The ASP's decisions on deposits it has not decided yet: it decides only final ones. Returns the ASP's status, if it
|
|
576
|
+
* was read. */
|
|
577
|
+
export async function readDecisions(asp: Asp, deposits: Watched[]): Promise<AspStatus | null> {
|
|
578
|
+
const undecided = deposits.flatMap((d) => d.found).filter((f) => !f.spent && f.asp === null);
|
|
579
|
+
if (undecided.length === 0) return null;
|
|
580
|
+
const status = await asp.status();
|
|
581
|
+
const bitmaps = new Map<number, Uint8Array>();
|
|
582
|
+
for (const f of undecided.filter((f) => f.index < status.decided)) {
|
|
583
|
+
const chunk = Math.floor(f.index / PAGE);
|
|
584
|
+
if (!bitmaps.has(chunk)) bitmaps.set(chunk, await asp.bitmap(chunk));
|
|
585
|
+
const j = f.index % PAGE;
|
|
586
|
+
f.asp = (bitmaps.get(chunk)![j >> 3] >> (j & 7)) & 1 ? 'approved' : 'rejected';
|
|
587
|
+
}
|
|
588
|
+
return status;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** When the ASP should decide each deposit it has not: once its block is final and `delaySecs` old. Taken from block
|
|
592
|
+
* numbers, the latest block and the final one, so that no server is asked about a block of this wallet's. */
|
|
593
|
+
async function estimateDecisions(
|
|
594
|
+
c: ChainCtx,
|
|
595
|
+
finalBlock: number | null,
|
|
596
|
+
delaySecs: number,
|
|
597
|
+
deposits: Watched[],
|
|
598
|
+
): Promise<void> {
|
|
599
|
+
const undecided = deposits.flatMap((d) => d.found).filter((f) => !f.spent && f.asp === null);
|
|
600
|
+
if (undecided.length === 0 || finalBlock === null) return;
|
|
601
|
+
const [latest, final] = await Promise.all([
|
|
602
|
+
c.chain.client.getBlock(),
|
|
603
|
+
c.chain.client.getBlock({ blockNumber: BigInt(finalBlock) }),
|
|
604
|
+
]);
|
|
605
|
+
const at = (b: typeof latest) => ({ number: Number(b.number), timestamp: Number(b.timestamp) });
|
|
606
|
+
const pages = new Map<number, Uint8Array>();
|
|
607
|
+
for (const f of undecided) {
|
|
608
|
+
const p = Math.floor(f.index / PAGE);
|
|
609
|
+
if (!pages.has(p)) pages.set(p, await c.indexer.page('blocks', p));
|
|
610
|
+
const page = pages.get(p)!;
|
|
611
|
+
const o = (f.index % PAGE) * 8;
|
|
612
|
+
if (page.length < o + 8) continue; // not served yet
|
|
613
|
+
const block = Number(new DataView(page.buffer, page.byteOffset).getBigUint64(o, false));
|
|
614
|
+
f.decidedByMs = decisionEstimate(block, at(latest), at(final), delaySecs) ?? f.decidedByMs;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** When a deposit in `block` should be decided (ms), with blocks taken to come evenly from the final block to the
|
|
619
|
+
* latest, and to become final as late after their time as the final one has: null where no block came in between. */
|
|
620
|
+
export function decisionEstimate(
|
|
621
|
+
block: number,
|
|
622
|
+
latest: { number: number; timestamp: number },
|
|
623
|
+
final: { number: number; timestamp: number },
|
|
624
|
+
delaySecs: number,
|
|
625
|
+
): number | null {
|
|
626
|
+
const blocks = latest.number - final.number;
|
|
627
|
+
if (blocks <= 0) return null;
|
|
628
|
+
const lag = latest.timestamp - final.timestamp;
|
|
629
|
+
const time = latest.timestamp - ((latest.number - block) * lag) / blocks;
|
|
630
|
+
return (time + Math.max(delaySecs, lag)) * 1000;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Records tag hits on the notes and deposits they belong to, through one map from key to owners: a restore can
|
|
634
|
+
* bring thousands of hits for thousands of notes. */
|
|
635
|
+
export function applyHits(
|
|
636
|
+
notes: NoteRecord[],
|
|
637
|
+
deposits: Pick<DepositRecord, 'watches' | 'found'>[],
|
|
638
|
+
hits: Hit[],
|
|
639
|
+
): void {
|
|
640
|
+
type Owner = { note: NoteRecord } | { deposit: Pick<DepositRecord, 'found'>; tokenId: number };
|
|
641
|
+
const owners = new Map<string, Owner[]>();
|
|
642
|
+
const add = (key: string, o: Owner) => {
|
|
643
|
+
const list = owners.get(key);
|
|
644
|
+
if (list) list.push(o);
|
|
645
|
+
else owners.set(key, [o]);
|
|
646
|
+
};
|
|
647
|
+
for (const n of notes) if (n.watch) add(n.watch.key, { note: n });
|
|
648
|
+
for (const d of deposits) for (const w of d.watches) add(w.watch.key, { deposit: d, tokenId: w.tokenId });
|
|
649
|
+
for (const hit of hits) {
|
|
650
|
+
for (const o of owners.get(hit.key) ?? []) {
|
|
651
|
+
const at = { index: hit.position, tentative: hit.tentative, spent: false };
|
|
652
|
+
if ('note' in o) place(o.note.found, { ...at, confirmed: false });
|
|
653
|
+
else place(o.deposit.found, { ...at, tokenId: o.tokenId, sender: null, amount: null, asp: null });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Records a found position once. A later final sighting replaces a tentative one, with whatever was read of it (a
|
|
659
|
+
* reorg may have moved what sat there, and to another block), but not that this wallet spent it. */
|
|
660
|
+
function place<T extends { index: number; tentative: boolean; spent: boolean; atMs?: number }>(
|
|
661
|
+
found: T[],
|
|
662
|
+
hit: T,
|
|
663
|
+
): void {
|
|
664
|
+
const same = found.find((f) => f.index === hit.index);
|
|
665
|
+
if (!same) found.push(hit);
|
|
666
|
+
else if (same.tentative && !hit.tentative) {
|
|
667
|
+
Object.assign(same, hit, { spent: same.spent });
|
|
668
|
+
delete same.atMs;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Drops each position seen before it was final that the final count has passed without finding it there: a reorg took
|
|
673
|
+
* it away (it shows up again where it went). The final scan covers every tentative position's index, as its watch is
|
|
674
|
+
* kept until the position is final. */
|
|
675
|
+
export function dropReorged(records: { found: { index: number; tentative: boolean }[] }[], finalCount: number): void {
|
|
676
|
+
for (const r of records) r.found = r.found.filter((f) => !f.tentative || f.index >= finalCount);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Everything new since the last sync, then saved. A chain or storage that fails holds back only itself: the rest go
|
|
680
|
+
* on, and its error is returned. */
|
|
681
|
+
export async function syncAll(ctx: Ctx): Promise<unknown[]> {
|
|
682
|
+
const errors: unknown[] = [];
|
|
683
|
+
const kept = <T>(p: Promise<T>): Promise<T | null> => p.catch((e: unknown) => (errors.push(e), null));
|
|
684
|
+
const listed = await Promise.all(
|
|
685
|
+
ctx.chains.map((c) => kept(c.chain.tokens().then((ts) => [c.config.chainId, ts.map((t) => t.tokenId)] as const))),
|
|
686
|
+
);
|
|
687
|
+
const tokens = new Map(listed.filter((l) => l !== null));
|
|
688
|
+
// storage announces a record once, and a refresh drops one of a chain or token it is not given: only with them all
|
|
689
|
+
if (tokens.size === ctx.chains.length) await kept(syncStorage(ctx, tokens));
|
|
690
|
+
// side by side: each changes only its own chain's records. Every one settles before the next sync can start
|
|
691
|
+
await Promise.all(
|
|
692
|
+
ctx.chains
|
|
693
|
+
.filter((c) => tokens.has(c.config.chainId))
|
|
694
|
+
.map((c) => kept(syncChain(ctx, c, tokens.get(c.config.chainId)!))),
|
|
695
|
+
);
|
|
696
|
+
await ctx.save();
|
|
697
|
+
return errors;
|
|
698
|
+
}
|