@zerc20/storage-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/src/send.ts ADDED
@@ -0,0 +1,224 @@
1
+ // Planning payments, deposits, notes to self, burn blocks and points of sale from a synced state, and submitting them.
2
+ // A point of sale writes its own lane with its key alone (`writePosBlock`).
3
+ //
4
+ // Write the note here first, then create the on-chain note with the same opening: a note that exists
5
+ // only on chain cannot be found. Receivers match notes one-to-one to on-chain leaves (by
6
+ // `commitment_index`) and treat the unmatched as pending.
7
+
8
+ import type { Action } from './candid.js';
9
+ import { type CanisterClient, StorageError } from './canister.js';
10
+ import { equal, seal, sealTo } from './crypto.js';
11
+ import {
12
+ type BurnBlock,
13
+ type NoteOpening,
14
+ type OwnReceipt,
15
+ type Receipt,
16
+ type ReceiveCode,
17
+ burnBlockPlaintext,
18
+ channelKey,
19
+ channelPlaintext,
20
+ channelSeed,
21
+ depositPlaintext,
22
+ notePlaintext,
23
+ outgoingId,
24
+ outgoingKey,
25
+ ownKey,
26
+ ownReceiptPlaintext,
27
+ posLanePlaintext,
28
+ sealKey,
29
+ slotId,
30
+ targetPlaintext,
31
+ } from './formats.js';
32
+ import { type ChainLane, type NewRecord, type SyncState, checkState, refresh, sequenceEnd } from './sync.js';
33
+ import type { Recipient, ViewKey } from './viewKey.js';
34
+
35
+ /** The record at `slotIndex` of the lane of `laneKey` on `chainId`. */
36
+ function put(laneKey: Uint8Array, chainId: bigint, slotIndex: number, plaintext: Uint8Array): Action {
37
+ const id = slotId(laneKey, chainId, slotIndex);
38
+ return { Put: { kind: { Lane: null }, id, data: seal(sealKey(laneKey), id, plaintext) } };
39
+ }
40
+
41
+ /** The next free slot of a lane `state` has read, which must be on a watched chain. */
42
+ function nextSlotIndex(state: SyncState, chains: ChainLane[] | undefined, chainId: bigint): number {
43
+ if (!state.chains.includes(chainId)) throw new Error(`watch chain ${chainId} and refresh first`);
44
+ return chains?.find((c) => c.chainId === chainId)?.nextSlotIndex ?? 0;
45
+ }
46
+
47
+ function plaintextOf(plain: Uint8Array | null): Uint8Array {
48
+ if (!plain) throw new Error('out of range: value ≥ 2^248 or salt ≥ Fr');
49
+ return plain;
50
+ }
51
+
52
+ /** A record for `recipient` on `chainId`, through the channel to it, which is opened first if there is none yet. */
53
+ function toAddress(
54
+ view: ViewKey,
55
+ state: SyncState,
56
+ recipient: Recipient,
57
+ chainId: bigint,
58
+ plaintext: Uint8Array,
59
+ ): Action[] {
60
+ checkState(view, state);
61
+ const actions: Action[] = [];
62
+ const { inbox } = recipient;
63
+ const channel = state.outgoing.lanes.find((c) => c.inbox && equal(c.inbox, inbox));
64
+ let key = channel?.key;
65
+ if (!key) {
66
+ // first contact: announce `payer ‖ seed` to the receiver, and record the channel for the payer
67
+ const c = channelSeed(view.seed, view.address, inbox);
68
+ const enc_announcement = sealTo(recipient.notifyKey, 'announce', inbox, channelPlaintext(view.address, c));
69
+ if (!enc_announcement) throw new Error('low-order notify key');
70
+ const outgoing_id = outgoingId(view.seed, state.outgoing.next);
71
+ const target = { address: recipient.address, notifyKey: recipient.notifyKey };
72
+ const enc_outgoing = seal(outgoingKey(view.seed), outgoing_id, targetPlaintext(target));
73
+ actions.push({ OpenChannel: { inbox, enc_announcement, outgoing_id, enc_outgoing } });
74
+ key = channelKey(c, inbox, view.address);
75
+ }
76
+ actions.push(put(key, chainId, nextSlotIndex(state, channel?.chains, chainId), plaintext));
77
+ return actions;
78
+ }
79
+
80
+ /** A note with `opening` (what the on-chain note commits to) for `recipient`, as `lookupRecipient` found it. The
81
+ * on-chain note must be owned by `recipient.publicKey`. */
82
+ export function planSend(view: ViewKey, state: SyncState, recipient: Recipient, opening: NoteOpening): Action[] {
83
+ return toAddress(view, state, recipient, opening.chainId, plaintextOf(notePlaintext(opening)));
84
+ }
85
+
86
+ /** Tells `recipient` of a deposit of `value` of `tokenId` on `chainId` into its burn address of `burnSalt`, under
87
+ * `recipient.publicKey`, which the payer then pays by a plain transfer. */
88
+ export function planDeposit(
89
+ view: ViewKey,
90
+ state: SyncState,
91
+ recipient: Recipient,
92
+ deposit: { chainId: bigint; tokenId: number; value: Uint8Array; burnSalt: Uint8Array; burnAddress: Uint8Array },
93
+ ): Action[] {
94
+ const plaintext = plaintextOf(
95
+ depositPlaintext(deposit.tokenId, deposit.value, deposit.burnSalt, deposit.burnAddress),
96
+ );
97
+ return toAddress(view, state, recipient, deposit.chainId, plaintext);
98
+ }
99
+
100
+ /** A note with `opening` paying `code`; the on-chain note must be owned by `code.publicKey`. Every payer of the code
101
+ * writes to its one lane, naming itself: the first payment records the code for the payer's history and looks for
102
+ * the lane's end, later ones take it from `state`. Another payer writing first makes the submit `Occupied`, which
103
+ * `commit` plans again. */
104
+ export async function planPayCode(
105
+ client: CanisterClient,
106
+ view: ViewKey,
107
+ state: SyncState,
108
+ code: ReceiveCode,
109
+ opening: NoteOpening,
110
+ ): Promise<Action[]> {
111
+ if (opening.chainId !== code.chainId || opening.tokenId !== code.tokenId)
112
+ throw new Error("the note is not for the code's chain and token");
113
+ const plaintext = plaintextOf(notePlaintext(opening, { payer: view.address }));
114
+ checkState(view, state);
115
+ const { chainId, key } = code;
116
+ const lane = state.outgoing.lanes.find((c) => equal(c.key, key));
117
+ if (lane) return [put(key, chainId, nextSlotIndex(state, lane.chains, chainId), plaintext)];
118
+ nextSlotIndex(state, undefined, chainId); // a watched chain
119
+ const id = outgoingId(view.seed, state.outgoing.next);
120
+ const record = seal(outgoingKey(view.seed), id, targetPlaintext(code));
121
+ const end = await sequenceEnd(client, (i) => slotId(key, chainId, i));
122
+ return [{ Put: { kind: { Outgoing: null }, id, data: record } }, put(key, chainId, end, plaintext)];
123
+ }
124
+
125
+ /** A note to self in the own lane, which only the owner reads: the output of a merge, or the change of a payment.
126
+ * Put a payment's change in the payment's batch, so both are written or neither:
127
+ * `[...planSend(..), ...planSelf(..)]`. `receipt` says what the payment or merge was, for the owner's other devices; a
128
+ * zero-value change is unspendable, and worth writing only to carry it. */
129
+ export function planSelf(view: ViewKey, state: SyncState, opening: NoteOpening, receipt: Receipt): Action[] {
130
+ checkState(view, state);
131
+ const plaintext = plaintextOf(notePlaintext(opening, { receipt }));
132
+ return [put(ownKey(view.seed), opening.chainId, nextSlotIndex(state, state.own, opening.chainId), plaintext)];
133
+ }
134
+
135
+ /** What the owner did that leaves no note to self (a rage exit, a deposit address registered), in the own lane. */
136
+ export function planReceipt(view: ViewKey, state: SyncState, receipt: OwnReceipt): Action[] {
137
+ checkState(view, state);
138
+ return [
139
+ put(
140
+ ownKey(view.seed),
141
+ receipt.chainId,
142
+ nextSlotIndex(state, state.own, receipt.chainId),
143
+ ownReceiptPlaintext(receipt),
144
+ ),
145
+ ];
146
+ }
147
+
148
+ /** The record of `block` in the own lane, to submit before handing out any of its burn addresses, so that every
149
+ * device of the owner watches them. Record blocks ahead of need, not as an address is handed out: the time of the
150
+ * write would otherwise mark the first payment to it. */
151
+ export function planBurnBlock(view: ViewKey, state: SyncState, block: BurnBlock): Action[] {
152
+ checkState(view, state);
153
+ return [
154
+ put(ownKey(view.seed), block.chainId, nextSlotIndex(state, state.own, block.chainId), burnBlockPlaintext(block)),
155
+ ];
156
+ }
157
+
158
+ /** The number the owner's next point of sale takes: one past the highest the own lane records. */
159
+ export const nextPosLane = (state: SyncState): number =>
160
+ 1 + Math.max(-1, ...state.incoming.map((c) => (c.via.type === 'pos' ? c.via.n : -1)));
161
+
162
+ /** The record of point of sale `n` in the own lane, so that every device of the owner reads its lane (`posKey`). Plan
163
+ * it with `n = nextPosLane(state)` inside `commit`'s plan: after `Occupied`, another device took that number. */
164
+ export function planPosLane(view: ViewKey, state: SyncState, chainId: bigint, n: number): Action[] {
165
+ checkState(view, state);
166
+ return [put(ownKey(view.seed), chainId, nextSlotIndex(state, state.own, chainId), posLanePlaintext(n))];
167
+ }
168
+
169
+ /** Writes `block` to the lane of `laneKey`, as the point of sale that alone holds the key, at the lane's first empty
170
+ * slot from `from` on; returns the slot after it. A slot someone else took first is passed over, and the same salts
171
+ * written twice are found once. */
172
+ export async function writePosBlock(
173
+ client: CanisterClient,
174
+ laneKey: Uint8Array,
175
+ block: BurnBlock,
176
+ from = 0,
177
+ ): Promise<number> {
178
+ const plaintext = burnBlockPlaintext(block);
179
+ for (;;) {
180
+ const at = await sequenceEnd(client, (i) => slotId(laneKey, block.chainId, i), from);
181
+ try {
182
+ await submit(client, [put(laneKey, block.chainId, at, plaintext)]);
183
+ return at + 1;
184
+ } catch (e) {
185
+ if (!(e instanceof StorageError && e.occupied)) throw e;
186
+ from = at + 1;
187
+ }
188
+ }
189
+ }
190
+
191
+ /** Submits the actions `plan` builds from `state`, then refreshes; continue from the returned state,
192
+ * or the next plan reuses a slot that is taken. After `Occupied` (another device wrote there first)
193
+ * it refreshes and plans once more. Calls that fail without an answer are retried with the same
194
+ * actions, which is safe: writes are write-once and batches atomic. */
195
+ export async function commit<T extends Action[]>(
196
+ client: CanisterClient,
197
+ view: ViewKey,
198
+ state: SyncState,
199
+ plan: (state: SyncState) => T | Promise<T>,
200
+ ): Promise<{ planned: T; state: SyncState; added: NewRecord[] }> {
201
+ let planned = await plan(state);
202
+ // a record is announced by one refresh only: keep what the one between the attempts announced too
203
+ let added: NewRecord[] = [];
204
+ try {
205
+ await submit(client, planned);
206
+ } catch (e) {
207
+ if (!(e instanceof StorageError && e.occupied)) throw e;
208
+ ({ state, added } = await refresh(client, view, state));
209
+ planned = await plan(state);
210
+ await submit(client, planned);
211
+ }
212
+ const last = await refresh(client, view, state);
213
+ return { planned, state: last.state, added: [...added, ...last.added] };
214
+ }
215
+
216
+ async function submit(client: CanisterClient, actions: Action[]): Promise<void> {
217
+ for (let attempt = 1; ; attempt++) {
218
+ try {
219
+ return await client.submit(actions);
220
+ } catch (e) {
221
+ if (e instanceof StorageError || attempt === 3) throw e;
222
+ }
223
+ }
224
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,376 @@
1
+ // Incremental history. Everything is append-only or write-once, so a state remembers how far each
2
+ // list has been read and every refresh reads only past that. New records are found through the
3
+ // canister's log of lane slot ids: a refresh matches the next id of each of its lanes against the
4
+ // log's new entries, so its cost does not grow with the number of channels or receive codes.
5
+
6
+ import type { ActorSubclass } from '@icp-sdk/core/agent';
7
+ import type { Kind, Service, Slot } from './candid.js';
8
+ import type { CanisterClient } from './canister.js';
9
+ import { concat, equal, hex, kdf, open, openFrom, seal, unhex, utf8 } from './crypto.js';
10
+ import {
11
+ type LaneKind,
12
+ type LaneRecord,
13
+ type Via,
14
+ addressInbox,
15
+ channelKey,
16
+ channelSeed,
17
+ codeKey,
18
+ outgoingId,
19
+ outgoingKey,
20
+ ownKey,
21
+ posKey,
22
+ readChannel,
23
+ readLaneRecord,
24
+ readTarget,
25
+ sealKey,
26
+ slotId,
27
+ } from './formats.js';
28
+ import { type ViewKey, notifyKey, notifySecret } from './viewKey.js';
29
+ import { MAX_LOG_READ, MAX_READ } from './wire.js';
30
+
31
+ /** Slots read per sequence and round trip once a sequence has news. */
32
+ const BATCH = 16;
33
+
34
+ /** One lane on one chain. */
35
+ export interface ChainLane {
36
+ chainId: bigint;
37
+ /** Where the next record goes: the first empty slot index (slots that do not open count too). */
38
+ nextSlotIndex: number;
39
+ /** What it held, in slot order. An app that keeps records of its own may empty this. */
40
+ records: LaneRecord[];
41
+ }
42
+
43
+ /** A lane other than the own lane: one payer → receiver channel, a receive code's lane, which every payer of the code
44
+ * writes to, or the lane of one of the owner's points of sale. */
45
+ export interface StoredLane {
46
+ /** Outgoing: the receiver, or `null` for a code (which never names its receiver). Incoming: the payer as claimed
47
+ * by whoever opened the channel, or `null` for a code, whose notes each claim their own, and for a point of sale;
48
+ * it is NOT verified. */
49
+ peer: Uint8Array | null;
50
+ via: Via;
51
+ /** The channel seed and its inbox; `null` for a code and a point of sale, which have neither. */
52
+ seed: Uint8Array | null;
53
+ inbox: Uint8Array | null;
54
+ /** The lane key. */
55
+ key: Uint8Array;
56
+ /** One entry per watched chain. */
57
+ chains: ChainLane[];
58
+ }
59
+
60
+ /** Everything one view key has seen so far. Plain data: keep it with `exportState`, and treat it as read-only. */
61
+ export interface SyncState {
62
+ address: Uint8Array;
63
+ /** The chains whose lanes are read. Add one and the next refresh reads it from slot 0. */
64
+ chains: bigint[];
65
+ addressInboxRead: number;
66
+ /** Channels to the address, the address's own receive codes (one for each burn index in its burn blocks), and the
67
+ * lanes of its points of sale (one for each the own lane records). */
68
+ incoming: StoredLane[];
69
+ outgoing: { next: number; lanes: StoredLane[] };
70
+ /** The own lane (notes to self, burn blocks and points of sale), one entry per watched chain. */
71
+ own: ChainLane[];
72
+ /** How far the canister's lane log has been read; `null` until the first full read. */
73
+ log: number | null;
74
+ }
75
+
76
+ /** A record a refresh found. */
77
+ export interface NewRecord {
78
+ direction: 'incoming' | 'outgoing' | 'own';
79
+ /** `null` in the own lane. */
80
+ lane: StoredLane | null;
81
+ /** Who is on the other side, as claimed and unverified: an incoming channel's payer, an outgoing one's receiver,
82
+ * a note's payer in one of the owner's codes. `null` in the own lane and a point of sale's, and for a payment to a
83
+ * code. */
84
+ peer: Uint8Array | null;
85
+ record: LaneRecord;
86
+ }
87
+
88
+ /** Where records come from: a channel, a receive code, a point of sale, or the own lane. */
89
+ interface Source {
90
+ direction: NewRecord['direction'];
91
+ kind: LaneKind;
92
+ lane: StoredLane | null;
93
+ chains: ChainLane[];
94
+ key: Uint8Array;
95
+ /** Whom a record of it is with, or `undefined` to skip it. */
96
+ peerOf: (r: LaneRecord) => Uint8Array | null | undefined;
97
+ }
98
+
99
+ export const emptyState = (view: ViewKey, chains: bigint[]): SyncState => ({
100
+ address: view.address,
101
+ chains,
102
+ addressInboxRead: 0,
103
+ incoming: [],
104
+ outgoing: { next: 0, lanes: [] },
105
+ own: [],
106
+ log: null,
107
+ });
108
+
109
+ export function checkState(view: ViewKey, state: SyncState): void {
110
+ if (!equal(state.address, view.address)) throw new Error('the state belongs to another view key');
111
+ }
112
+
113
+ const payerOf = (r: LaneRecord): Uint8Array | null => (r.kind === 'note' ? (r.payer ?? null) : null);
114
+
115
+ /** Reads what is new since `state` and returns the extended state, plus the records it found. `state` itself is
116
+ * left untouched, so a failed refresh loses nothing. A first sync is a refresh of `emptyState`.
117
+ *
118
+ * Queries only spot news: whatever is kept is read again as update calls, so a single lying replica
119
+ * can neither plant entries nor make a cursor skip one. With nothing new it costs queries only: one
120
+ * for outgoing records, one for the address inbox, and one for the lane log.
121
+ *
122
+ * A replica can still leave log entries out, which only delays those records: `full` reads every
123
+ * lane instead (one query per 256 of them: every channel, point of sale and the own lane on every
124
+ * watched chain, every code on its own), as the first refresh of a state does. Do that now and then, say hourly. */
125
+ export async function refresh(
126
+ client: CanisterClient,
127
+ view: ViewKey,
128
+ state: SyncState,
129
+ opts: { full?: boolean } = {},
130
+ ): Promise<{ state: SyncState; added: NewRecord[] }> {
131
+ checkState(view, state);
132
+ const next = structuredClone(state);
133
+ // read before anything else: writes made while this refresh runs are found by the next one
134
+ const fromLog = opts.full || next.log === null ? null : next.log;
135
+ const log =
136
+ fromLog === null ? { end: Number(await client.actor.log_length()), ids: [] } : await readLog(client, fromLog);
137
+ const news = new Set(log.ids.map((id) => hex(id)));
138
+ const { address, seed } = view;
139
+ const added: NewRecord[] = [];
140
+
141
+ /** The sequences of `sources` with news: a lane found in this refresh, or a chain just watched, may hold records
142
+ * from before the log position. */
143
+ const due = (sources: Source[]) =>
144
+ sources.flatMap((source) => {
145
+ const via = source.lane?.via;
146
+ const chainIds =
147
+ via?.type === 'code'
148
+ ? next.chains.filter((c) => c === ('code' in via ? via.code.chainId : via.chainId))
149
+ : next.chains;
150
+ return chainIds.flatMap((chainId) => {
151
+ let chain = source.chains.find((c) => c.chainId === chainId);
152
+ const fresh = !chain;
153
+ if (!chain) source.chains.push((chain = { chainId, nextSlotIndex: 0, records: [] }));
154
+ const idAt = (i: number) => slotId(source.key, chainId, i);
155
+ const isDue = fromLog === null || fresh || news.has(hex(idAt(chain.nextSlotIndex)));
156
+ return isDue ? [{ source, chain, next: chain.nextSlotIndex, idAt }] : [];
157
+ });
158
+ });
159
+ /** Takes what `sequences` held, every filled slot counting (so that a code's payer writes past the other payers'
160
+ * notes), and returns the records it took. */
161
+ const take = (sequences: ReturnType<typeof due>, found: Filled[][]) =>
162
+ sequences.flatMap(({ source, chain }, s) =>
163
+ found[s].flatMap(({ id, slot }) => {
164
+ const slotIndex = chain.nextSlotIndex++;
165
+ const plain = open(sealKey(source.key), id, slot.data);
166
+ const record = plain && readLaneRecord(source.kind, chain.chainId, slotIndex, slot.at_ns, plain);
167
+ const peer = record && source.peerOf(record);
168
+ if (!record || peer === undefined) return [];
169
+ chain.records.push(record);
170
+ added.push({ direction: source.direction, lane: source.lane, peer, record });
171
+ return [record];
172
+ }),
173
+ );
174
+
175
+ // The own lane and outgoing records past the cursors, perhaps written by another device. The own lane comes
176
+ // first, so that the codes of a new burn block, and a new point of sale's lane, are read in this refresh.
177
+ const own: Source = {
178
+ direction: 'own',
179
+ kind: 'own',
180
+ lane: null,
181
+ chains: next.own,
182
+ key: ownKey(seed),
183
+ peerOf: () => null,
184
+ };
185
+ const ownSequences = due([own]);
186
+ const [ownFound, records] = await Promise.all([
187
+ readSequences(client, { Lane: null }, ownSequences),
188
+ readSequences(client, { Outgoing: null }, [{ next: next.outgoing.next, idAt: (i) => outgoingId(seed, i) }]),
189
+ ]);
190
+ // two devices may record the same index, or the same point of sale: one lane each
191
+ const lanesHeld = new Set(next.incoming.filter((c) => c.via.type !== 'address').map((c) => hex(c.key)));
192
+ const hold = (via: Via, key: Uint8Array) => {
193
+ if (lanesHeld.has(hex(key))) return;
194
+ lanesHeld.add(hex(key));
195
+ next.incoming.push({ peer: null, via, seed: null, inbox: null, key, chains: [] });
196
+ };
197
+ for (const r of take(ownSequences, ownFound)) {
198
+ if (r.kind === 'pos-lane') hold({ type: 'pos', n: r.n }, posKey(seed, r.n));
199
+ if (r.kind !== 'burn-block') continue;
200
+ r.salts.forEach((_, i) => {
201
+ const index = r.first + i;
202
+ hold({ type: 'code', chainId: r.chainId, tokenId: r.tokenId, index }, codeKey(seed, r.chainId, r.tokenId, index));
203
+ });
204
+ }
205
+ for (const { id, slot } of records[0]) {
206
+ next.outgoing.next++;
207
+ const target = readTarget(open(outgoingKey(seed), id, slot.data) ?? new Uint8Array());
208
+ if (!target) continue;
209
+ if (!('address' in target)) {
210
+ next.outgoing.lanes.push({
211
+ peer: null,
212
+ via: { type: 'code', code: target },
213
+ seed: null,
214
+ inbox: null,
215
+ key: target.key,
216
+ chains: [],
217
+ });
218
+ continue;
219
+ }
220
+ const inbox = addressInbox(target.address, target.notifyKey);
221
+ const c = channelSeed(seed, address, inbox);
222
+ const channel = {
223
+ peer: target.address,
224
+ via: { type: 'address' } as const,
225
+ seed: c,
226
+ inbox,
227
+ key: channelKey(c, inbox, address),
228
+ chains: [],
229
+ };
230
+ next.outgoing.lanes.push(channel);
231
+ }
232
+
233
+ // New announcements in the address inbox, once per seed. Deduplicating by payer instead would let a forged entry
234
+ // naming a real payer hide the real channel.
235
+ const inbox = addressInbox(address, notifyKey(view));
236
+ const infos = await readInbox(client, inbox, next.addressInboxRead);
237
+ next.addressInboxRead += infos.length;
238
+ for (const info of infos) {
239
+ const announced = readChannel(openFrom(notifySecret(view), 'announce', inbox, info) ?? new Uint8Array());
240
+ if (!announced || next.incoming.some((channel) => channel.seed && equal(channel.seed, announced.seed))) continue;
241
+ const { payer, seed: c } = announced;
242
+ next.incoming.push({
243
+ peer: payer,
244
+ via: { type: 'address' },
245
+ seed: c,
246
+ inbox,
247
+ key: channelKey(c, inbox, payer),
248
+ chains: [],
249
+ });
250
+ }
251
+
252
+ // New records in every channel and point of sale on every watched chain, and in every code on its own. Of a code's
253
+ // lane, the owner takes every payer's notes, and a payer only its own.
254
+ const sources: Source[] = [
255
+ ...next.incoming.map((lane): Source => ({
256
+ direction: 'incoming',
257
+ kind: lane.via.type === 'address' ? 'channel' : lane.via.type,
258
+ lane,
259
+ chains: lane.chains,
260
+ key: lane.key,
261
+ peerOf: (r) => (lane.via.type === 'code' ? payerOf(r) : lane.peer),
262
+ })),
263
+ ...next.outgoing.lanes.map((lane): Source => ({
264
+ direction: 'outgoing',
265
+ kind: lane.via.type === 'code' ? 'code' : 'channel',
266
+ lane,
267
+ chains: lane.chains,
268
+ key: lane.key,
269
+ peerOf: (r) => (lane.via.type !== 'code' ? lane.peer : equal(payerOf(r)!, address) ? null : undefined),
270
+ })),
271
+ ];
272
+ const sequences = due(sources);
273
+ take(sequences, await readSequences(client, { Lane: null }, sequences));
274
+ next.log = log.end;
275
+ return { state: next, added };
276
+ }
277
+
278
+ /** The lane log from `start` to its end. */
279
+ async function readLog(client: CanisterClient, start: number): Promise<{ end: number; ids: Uint8Array[] }> {
280
+ const ids: Uint8Array[] = [];
281
+ for (;;) {
282
+ const page = await client.actor.get_log(BigInt(start + ids.length), MAX_LOG_READ);
283
+ ids.push(...page);
284
+ if (page.length < MAX_LOG_READ) return { end: start + ids.length, ids };
285
+ }
286
+ }
287
+
288
+ /** Announcements from `start` on. */
289
+ async function readInbox(client: CanisterClient, inbox: Uint8Array, start: number): Promise<Uint8Array[]> {
290
+ const infos: Uint8Array[] = [];
291
+ for (;;) {
292
+ const at = start + infos.length;
293
+ const peek = await client.actor.get_announcements(inbox, at, MAX_READ);
294
+ if (peek.length === 0) return infos;
295
+ const page = await client.replicated.get_announcements(inbox, at, MAX_READ);
296
+ infos.push(...page);
297
+ if (page.length < MAX_READ) return infos;
298
+ }
299
+ }
300
+
301
+ interface Sequence {
302
+ /** First slot index not read yet. */
303
+ next: number;
304
+ idAt: (index: number) => Uint8Array;
305
+ }
306
+
307
+ type Filled = { id: Uint8Array; slot: Slot };
308
+
309
+ /** The filled slots of each sequence from its `next` index up to the first empty one. The first round
310
+ * checks one slot per sequence; sequences with news are then read `BATCH` slots at a time. */
311
+ async function readSequences(client: CanisterClient, kind: Kind, sequences: Sequence[]): Promise<Filled[][]> {
312
+ const found: Filled[][] = sequences.map(() => []);
313
+ let pending = sequences.map((_, s) => s);
314
+ for (let width = 1; pending.length > 0; width = BATCH) {
315
+ const ids = pending.map((s) => {
316
+ const { next, idAt } = sequences[s];
317
+ return Array.from({ length: width }, (_, i) => idAt(next + found[s].length + i));
318
+ });
319
+ const peek = await getSlots(client.actor, kind, ids.flat());
320
+ const news = ids.map((row, p) => row.slice(0, leading(peek.slice(p * width, (p + 1) * width))));
321
+ const confirmed = await getSlots(client.replicated, kind, news.flat());
322
+ let at = 0;
323
+ pending = pending.filter((s, p) => {
324
+ const slots = confirmed.slice(at, (at += news[p].length));
325
+ const n = leading(slots);
326
+ found[s].push(...slots.slice(0, n).map((slot, i) => ({ id: news[p][i], slot: slot! })));
327
+ return n === width;
328
+ });
329
+ }
330
+ return found;
331
+ }
332
+
333
+ /** `get_slots` in reads of at most `MAX_READ` ids. */
334
+ async function getSlots(actor: ActorSubclass<Service>, kind: Kind, ids: Uint8Array[]): Promise<(Slot | null)[]> {
335
+ const reads = [];
336
+ for (let i = 0; i < ids.length; i += MAX_READ) reads.push(actor.get_slots(kind, ids.slice(i, i + MAX_READ)));
337
+ return (await Promise.all(reads)).flat().map((slot) => slot[0] ?? null);
338
+ }
339
+
340
+ /** The first empty slot of a lane's sequence from slot `from` on: where a code's first payment goes, or a point of
341
+ * sale's next block. */
342
+ export async function sequenceEnd(
343
+ client: CanisterClient,
344
+ idAt: (index: number) => Uint8Array,
345
+ from = 0,
346
+ ): Promise<number> {
347
+ return from + (await readSequences(client, { Lane: null }, [{ next: from, idAt }]))[0].length;
348
+ }
349
+
350
+ const leading = (slots: (Slot | null)[]): number => {
351
+ const empty = slots.indexOf(null);
352
+ return empty < 0 ? slots.length : empty;
353
+ };
354
+
355
+ const STATE_TAG = utf8('zerc20/storage/sync-state/v1');
356
+
357
+ /** `state`, encrypted under a key only `view` derives, for the app to store anywhere. */
358
+ export function exportState(view: ViewKey, state: SyncState): Uint8Array {
359
+ checkState(view, state);
360
+ const json = JSON.stringify(state, (_, v: unknown) =>
361
+ typeof v === 'bigint' ? { $n: v.toString() } : v instanceof Uint8Array ? { $b: hex(v) } : v,
362
+ );
363
+ return seal(kdf(view.seed, 'sync-state'), concat(STATE_TAG, view.address), utf8(json));
364
+ }
365
+
366
+ /** The state `exportState` encrypted, or `null` if `bytes` is not one for `view` (start over from
367
+ * `emptyState` then). */
368
+ export function importState(view: ViewKey, bytes: Uint8Array): SyncState | null {
369
+ const plain = open(kdf(view.seed, 'sync-state'), concat(STATE_TAG, view.address), bytes);
370
+ if (!plain) return null;
371
+ return JSON.parse(new TextDecoder().decode(plain), (_, v: unknown) => {
372
+ if (typeof v === 'object' && v !== null && '$n' in v) return BigInt(v.$n as string);
373
+ if (typeof v === 'object' && v !== null && '$b' in v) return unhex(v.$b as string);
374
+ return v;
375
+ }) as SyncState;
376
+ }