@zerc20/wallet-sdk 0.2.0 → 0.4.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/burnWorker.js +6 -6
- package/dist/burnWorker.js.map +1 -1
- package/dist/context.d.ts +25 -4
- package/dist/context.js +36 -6
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +32 -17
- package/dist/core.js +253 -70
- package/dist/core.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/pos.js +2 -2
- package/dist/pos.js.map +1 -1
- package/dist/rageExit.js +3 -5
- package/dist/rageExit.js.map +1 -1
- package/dist/seed.d.ts +4 -0
- package/dist/seed.js +12 -1
- package/dist/seed.js.map +1 -1
- package/dist/spend.d.ts +1 -1
- package/dist/spend.js +5 -4
- package/dist/spend.js.map +1 -1
- package/dist/state.d.ts +14 -4
- package/dist/state.js +10 -7
- package/dist/state.js.map +1 -1
- package/dist/sync.d.ts +16 -3
- package/dist/sync.js +93 -25
- package/dist/sync.js.map +1 -1
- package/dist/viewKey.d.ts +14 -0
- package/dist/viewKey.js +45 -0
- package/dist/viewKey.js.map +1 -0
- package/dist/wallet.d.ts +39 -12
- package/dist/wallet.js +209 -85
- package/dist/wallet.js.map +1 -1
- package/package.json +5 -3
- package/src/burnWorker.ts +9 -10
- package/src/context.ts +51 -8
- package/src/core.ts +279 -87
- package/src/errors.ts +2 -0
- package/src/index.ts +4 -1
- package/src/pos.ts +2 -2
- package/src/rageExit.ts +3 -4
- package/src/seed.ts +13 -1
- package/src/spend.ts +5 -5
- package/src/state.ts +17 -7
- package/src/sync.ts +97 -24
- package/src/viewKey.ts +49 -0
- package/src/wallet.ts +270 -87
- package/wasm/zerc20_wallet_core.d.ts +20 -10
- package/wasm/zerc20_wallet_core.js +74 -37
- package/wasm/zerc20_wallet_core_bg.wasm +0 -0
- package/wasm/zerc20_wallet_core_bg.wasm.d.ts +4 -3
package/src/sync.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type OwnReceipt,
|
|
10
10
|
type Receipt,
|
|
11
11
|
type SyncState,
|
|
12
|
+
burnKey,
|
|
12
13
|
commit,
|
|
13
14
|
emptyState,
|
|
14
15
|
planBurnBlock,
|
|
@@ -16,12 +17,12 @@ import {
|
|
|
16
17
|
refresh,
|
|
17
18
|
} from '@zerc20/storage-sdk';
|
|
18
19
|
import { type Hex, bytesToHex, hexToBytes, numberToHex, parseAbiItem } from 'viem';
|
|
19
|
-
import type { Chain } from './chain.js';
|
|
20
|
-
import { type ChainCtx, type Ctx, chainCtx, chainState,
|
|
20
|
+
import type { Chain, Token } from './chain.js';
|
|
21
|
+
import { type ChainCtx, type Ctx, chainCtx, chainState, owner } from './context.js';
|
|
21
22
|
import { burnAddress, core } from './core.js';
|
|
22
23
|
import { type Hit, type Watch, scan } from './scan.js';
|
|
23
24
|
import { type Asp, type AspStatus, type Indexer, PAGE } from './servers.js';
|
|
24
|
-
import type { DepositRecord, NoteReceipt, NoteRecord, Sample } from './state.js';
|
|
25
|
+
import type { DepositRecord, NoteReceipt, NoteRecord, Position, Sample } from './state.js';
|
|
25
26
|
|
|
26
27
|
/** The ledger's log of each commitment, found by its index (`contracts/src/CommitmentLedger.sol`). */
|
|
27
28
|
const committedEvent = parseAbiItem('event Committed(uint64 indexed index, uint256[4] fields)');
|
|
@@ -47,7 +48,7 @@ export function commitmentsAt(samples: Sample[], atMs: number): number {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** 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
|
+
* tag. Null while that key's owner is not derived. */
|
|
51
52
|
function watchFor(
|
|
52
53
|
ctx: Ctx,
|
|
53
54
|
chainId: bigint,
|
|
@@ -57,8 +58,9 @@ function watchFor(
|
|
|
57
58
|
keyIndex: number | undefined,
|
|
58
59
|
from: number,
|
|
59
60
|
) {
|
|
60
|
-
const
|
|
61
|
-
|
|
61
|
+
const key = owner(ctx, keyIndex === undefined ? undefined : { chainId, tokenId, index: keyIndex });
|
|
62
|
+
if (!key) return null;
|
|
63
|
+
const noteHash = core.noteHash(chainId, key.publicKey, `0x${value.toString(16)}`, tokenId, salt) as Hex;
|
|
62
64
|
return { noteHash, watch: { key: numberToHex(core.noteTag(tokenId, noteHash), { size: 4 }), from, scannedTo: from } };
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -214,7 +216,7 @@ export function takeDeposit(ctx: Ctx, deposit: Deposit, payer: Uint8Array, token
|
|
|
214
216
|
if (!tokens.get(chainId)?.includes(tokenId)) return;
|
|
215
217
|
const salt = hex32(deposit.burnSalt);
|
|
216
218
|
if (ctx.state.deposits.some((d) => d.chainId === chainId && d.salt === salt)) return;
|
|
217
|
-
const address = core.burnAddressOf(chainId,
|
|
219
|
+
const address = core.burnAddressOf(chainId, owner(ctx)!.publicKey, salt) as Hex | undefined;
|
|
218
220
|
if (!address) return;
|
|
219
221
|
const atMs = Number(deposit.atNs / 1_000_000n);
|
|
220
222
|
// storage held it before the payer's transfer; a day of margin covers clock skew
|
|
@@ -246,7 +248,7 @@ function takeBurnBlock(
|
|
|
246
248
|
tokens: Map<bigint, number[]>,
|
|
247
249
|
): void {
|
|
248
250
|
if (!tokens.has(b.chainId)) return;
|
|
249
|
-
const { publicKey } =
|
|
251
|
+
const { publicKey } = owner(ctx)!;
|
|
250
252
|
const atMs = Number(b.atNs / 1_000_000n);
|
|
251
253
|
const from = commitmentsAt(chainState(ctx, b.chainId).samples, atMs - DAY_MS);
|
|
252
254
|
b.salts.forEach((bytes, i) => {
|
|
@@ -285,8 +287,22 @@ export function unusedBurn(ctx: Ctx, chainId: bigint, tokenId: number): DepositR
|
|
|
285
287
|
return best;
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
/** The index of the next burn address of a token to record: past every one recorded, here or by another device. */
|
|
291
|
+
function toRecord(ctx: Ctx, chainId: bigint, tokenId: number): number {
|
|
292
|
+
let index = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
|
|
293
|
+
for (const d of ctx.state.deposits) {
|
|
294
|
+
if (d.chainId === chainId && d.tokenId === tokenId && !d.managed) index = Math.max(index, d.burnIndex + 1);
|
|
295
|
+
}
|
|
296
|
+
return index;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The index of the burn address of a token that is handed out next, and so of the receive code: the one recorded
|
|
300
|
+
* ahead, or else the next to record. */
|
|
301
|
+
export const nextBurn = (ctx: Ctx, chainId: bigint, tokenId: number): number =>
|
|
302
|
+
unusedBurn(ctx, chainId, tokenId)?.burnIndex ?? toRecord(ctx, chainId, tokenId);
|
|
303
|
+
|
|
288
304
|
/** 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. */
|
|
305
|
+
* of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes; needs no seed. */
|
|
290
306
|
export async function recordBurn(
|
|
291
307
|
ctx: Ctx,
|
|
292
308
|
chainId: bigint,
|
|
@@ -294,11 +310,9 @@ export async function recordBurn(
|
|
|
294
310
|
tokens: Map<bigint, number[]>,
|
|
295
311
|
signal?: AbortSignal,
|
|
296
312
|
): Promise<DepositRecord> {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
const { salt, address } = await burnAddress(ctx.seed, chainId, tokenId, index, signal);
|
|
313
|
+
const index = toRecord(ctx, chainId, tokenId);
|
|
314
|
+
const key = burnKey(ctx.view.seed);
|
|
315
|
+
const { salt, address } = await burnAddress(key, chainId, tokenId, index, owner(ctx)!.publicKey, signal);
|
|
302
316
|
const { commitmentsFinal: from } = await chainCtx(ctx, chainId).indexer.status();
|
|
303
317
|
const block = { chainId, tokenId, first: index, salts: [hexToBytes(salt)] };
|
|
304
318
|
const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planBurnBlock(ctx.view, s, block));
|
|
@@ -370,11 +384,45 @@ function upsertNote(ctx: Ctx, a: NewRecord, note: Note, tokens: Map<bigint, numb
|
|
|
370
384
|
peer,
|
|
371
385
|
atNs,
|
|
372
386
|
...(keyIndex === undefined ? {} : { keyIndex }),
|
|
373
|
-
|
|
387
|
+
// storage tells of a note once: one whose key waits for the seed is kept, not dropped
|
|
388
|
+
...(watchFor(ctx, chainId, tokenId, big(value), salt, keyIndex, from) ?? {
|
|
389
|
+
unkeyed: true,
|
|
390
|
+
noteHash: '0x',
|
|
391
|
+
watch: null,
|
|
392
|
+
}),
|
|
374
393
|
found: [],
|
|
375
394
|
});
|
|
376
395
|
}
|
|
377
396
|
|
|
397
|
+
/** Receive codes of each token whose keys are derived while the seed is held, to hand out while it is not. */
|
|
398
|
+
export const CODES_AHEAD = 4;
|
|
399
|
+
|
|
400
|
+
/** With the seed held, each owner not derived yet: the wallet's own, each of its burn addresses' (a receive code each),
|
|
401
|
+
* the next `CODES_AHEAD` codes' of each of `tokens`, and each note's key's; and the watch of each note that waited for
|
|
402
|
+
* its key. Without it, the watch of each note whose key was derived before. */
|
|
403
|
+
export function keyAll(ctx: Ctx, tokens: Token[]): void {
|
|
404
|
+
owner(ctx);
|
|
405
|
+
// ponytail: one derivation per own burn address as the wallet opens; keep owners in the saved state if wallets grow
|
|
406
|
+
// to thousands of them
|
|
407
|
+
for (const d of ctx.state.deposits) {
|
|
408
|
+
if (d.burnIndex >= 0 && !d.managed) owner(ctx, { chainId: d.chainId, tokenId: d.tokenId, index: d.burnIndex });
|
|
409
|
+
}
|
|
410
|
+
for (const { chainId, tokenId } of tokens) {
|
|
411
|
+
const next = nextBurn(ctx, chainId, tokenId);
|
|
412
|
+
for (let index = next; index < next + CODES_AHEAD; index++) owner(ctx, { chainId, tokenId, index });
|
|
413
|
+
}
|
|
414
|
+
for (const n of ctx.state.notes) {
|
|
415
|
+
if (n.keyIndex === undefined) continue;
|
|
416
|
+
owner(ctx, { chainId: n.chainId, tokenId: n.tokenId, index: n.keyIndex });
|
|
417
|
+
if (!n.unkeyed) continue;
|
|
418
|
+
const from = commitmentsAt(chainState(ctx, n.chainId).samples, Number((n.atNs ?? 0n) / 1_000_000n) - DAY_MS);
|
|
419
|
+
const watched = watchFor(ctx, n.chainId, n.tokenId, n.value, n.salt, n.keyIndex, from);
|
|
420
|
+
if (!watched) continue; // still waits for the seed
|
|
421
|
+
Object.assign(n, watched);
|
|
422
|
+
delete n.unkeyed;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
378
426
|
/** Whether something was started or announced recently enough to look past the final count for it. */
|
|
379
427
|
function waiting(ctx: Ctx, chainId: bigint, now: number): boolean {
|
|
380
428
|
const recent = (ms: number | null) => ms !== null && now - ms < WAIT_MS;
|
|
@@ -384,7 +432,12 @@ function waiting(ctx: Ctx, chainId: bigint, now: number): boolean {
|
|
|
384
432
|
(d) => d.chainId === chainId && d.found.length === 0 && recent(d.createdMs),
|
|
385
433
|
) ||
|
|
386
434
|
ctx.state.notes.some(
|
|
387
|
-
(n) =>
|
|
435
|
+
(n) =>
|
|
436
|
+
n.chainId === chainId &&
|
|
437
|
+
!n.unkeyed &&
|
|
438
|
+
n.found.length === 0 &&
|
|
439
|
+
n.atNs !== null &&
|
|
440
|
+
recent(Number(n.atNs / 1_000_000n)),
|
|
388
441
|
)
|
|
389
442
|
);
|
|
390
443
|
}
|
|
@@ -476,12 +529,13 @@ async function syncChain(ctx: Ctx, c: ChainCtx, tokens: number[]): Promise<void>
|
|
|
476
529
|
...deposits.flatMap((d) => d.found.map((f) => ({ f, code: undefined }))),
|
|
477
530
|
].filter(({ f }) => !f.tentative && !f.spent);
|
|
478
531
|
for (const { f, code } of owned) {
|
|
479
|
-
|
|
480
|
-
|
|
532
|
+
const key = owner(ctx, code);
|
|
533
|
+
if (f.nullifier || !key) continue; // without its owner: looked for once the seed is held
|
|
534
|
+
const nullifier = core.nullifier(chainId, BigInt(f.index), key.nullifyingKey);
|
|
481
535
|
const from = await nullifierFrom(f.index);
|
|
482
536
|
f.nullifier = { key: nullifier.slice(0, 18) as Hex, from, scannedTo: from };
|
|
483
537
|
}
|
|
484
|
-
const nullifierWatches = owned.
|
|
538
|
+
const nullifierWatches = owned.flatMap(({ f }) => (f.nullifier ? [f.nullifier] : []));
|
|
485
539
|
const spent = await scan(
|
|
486
540
|
nullifierWatches,
|
|
487
541
|
8,
|
|
@@ -489,13 +543,32 @@ async function syncChain(ctx: Ctx, c: ChainCtx, tokens: number[]): Promise<void>
|
|
|
489
543
|
status.nullifiersFinal,
|
|
490
544
|
live === status.commitments ? status.nullifiers : status.nullifiersFinal,
|
|
491
545
|
);
|
|
492
|
-
|
|
493
|
-
|
|
546
|
+
applySpends(
|
|
547
|
+
[...notes, ...deposits].flatMap((r): Position[] => r.found),
|
|
548
|
+
spent,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Records nullifier hits on the positions they spend: a final one spends it, one past the final count marks it
|
|
553
|
+
* `nullifiedAt`, until the final nullifiers pass that place without it (a reorg dropped that JoinSplit, which shows up
|
|
554
|
+
* again where it went). Every device sees an input gone once its JoinSplit is in a block, as the one that sent it does. */
|
|
555
|
+
export function applySpends(positions: Position[], hits: Hit[]): void {
|
|
556
|
+
const byNullifier = new Map(positions.filter((f) => f.nullifier).map((f) => [f.nullifier!.key, f]));
|
|
557
|
+
for (const hit of hits) {
|
|
494
558
|
const f = byNullifier.get(hit.key);
|
|
495
|
-
if (f
|
|
559
|
+
if (!f) continue;
|
|
560
|
+
if (hit.tentative) f.nullifiedAt = hit.position;
|
|
561
|
+
else f.spent = true;
|
|
562
|
+
}
|
|
563
|
+
for (const f of positions) {
|
|
564
|
+
// a spent position needs no more watching
|
|
565
|
+
if (f.spent) {
|
|
566
|
+
delete f.nullifier;
|
|
567
|
+
delete f.nullifiedAt;
|
|
568
|
+
} else if (f.nullifier && f.nullifiedAt !== undefined && f.nullifiedAt < f.nullifier.scannedTo) {
|
|
569
|
+
delete f.nullifiedAt;
|
|
570
|
+
}
|
|
496
571
|
}
|
|
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
572
|
}
|
|
500
573
|
|
|
501
574
|
/** The four fields of the commitment at an index, read from its page of commitments, each page once per reader: `null`
|
package/src/viewKey.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A view key as text: what `Wallet.watch` follows an account with, spending nothing.
|
|
2
|
+
|
|
3
|
+
import { bech32m } from '@scure/base';
|
|
4
|
+
import type { ViewKey } from '@zerc20/storage-sdk';
|
|
5
|
+
import { type Address, type Hex, bytesToHex, concat, getAddress, hexToBytes } from 'viem';
|
|
6
|
+
import type { Owner } from './context.js';
|
|
7
|
+
import { core } from './core.js';
|
|
8
|
+
|
|
9
|
+
const PREFIX = 'zerc20view';
|
|
10
|
+
|
|
11
|
+
/** `view` and the wallet's own `owner` as checksummed text (bech32m, `zerc20view1…`): `address(20) ‖ view seed(32) ‖
|
|
12
|
+
* auth key hash(32) ‖ nullifying key(32)`. The public key is left for the holder to make from the last two, so that a
|
|
13
|
+
* nullifying key that is not the account's makes a public key that is not either. */
|
|
14
|
+
export function encodeViewKey(view: ViewKey, owner: Owner): string {
|
|
15
|
+
const bytes = concat([view.address, view.seed, hexToBytes(owner.authKeyHash), hexToBytes(owner.nullifyingKey)]);
|
|
16
|
+
return bech32m.encode(PREFIX, bech32m.toWords(bytes), false);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function bytesOf(text: string): Uint8Array | null {
|
|
20
|
+
try {
|
|
21
|
+
const { prefix, bytes } = bech32m.decodeToBytes(text.trim(), false);
|
|
22
|
+
return prefix === PREFIX && bytes.length === 116 ? bytes : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The account `text` is a view key of, or `null` if it is not one: to tell a key apart before it is opened. */
|
|
29
|
+
export function viewKeyAccount(text: string): Address | null {
|
|
30
|
+
const bytes = bytesOf(text);
|
|
31
|
+
return bytes && getAddress(bytesToHex(bytes.subarray(0, 20)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The view key and owner `text` holds, or `null` for anything else. Needs `initCore`. */
|
|
35
|
+
export function decodeViewKey(text: string): { view: ViewKey; owner: Owner } | null {
|
|
36
|
+
const bytes = bytesOf(text);
|
|
37
|
+
if (!bytes) return null;
|
|
38
|
+
const authKeyHash = bytesToHex(bytes.subarray(52, 84));
|
|
39
|
+
const nullifyingKey = bytesToHex(bytes.subarray(84));
|
|
40
|
+
try {
|
|
41
|
+
const publicKey = core.publicKey(authKeyHash, nullifyingKey) as Hex;
|
|
42
|
+
return {
|
|
43
|
+
view: { address: bytes.slice(0, 20), seed: bytes.slice(20, 52) },
|
|
44
|
+
owner: { authKeyHash, publicKey, nullifyingKey },
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return null; // a value that is not a field element
|
|
48
|
+
}
|
|
49
|
+
}
|