@zerc20/wallet-sdk 0.0.0-stage → 0.1.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.
Files changed (84) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +26 -2
  3. package/dist/burnWorker.d.ts +1 -0
  4. package/dist/burnWorker.js +21 -0
  5. package/dist/burnWorker.js.map +1 -0
  6. package/dist/chain.d.ts +345 -0
  7. package/dist/chain.js +149 -0
  8. package/dist/chain.js.map +1 -0
  9. package/dist/context.d.ts +48 -0
  10. package/dist/context.js +40 -0
  11. package/dist/context.js.map +1 -0
  12. package/dist/core.d.ts +43 -0
  13. package/dist/core.js +106 -0
  14. package/dist/core.js.map +1 -0
  15. package/dist/errors.d.ts +36 -0
  16. package/dist/errors.js +26 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +19 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/network.d.ts +66 -0
  22. package/dist/network.js +55 -0
  23. package/dist/network.js.map +1 -0
  24. package/dist/pos.d.ts +93 -0
  25. package/dist/pos.js +302 -0
  26. package/dist/pos.js.map +1 -0
  27. package/dist/prove.d.ts +13 -0
  28. package/dist/prove.js +53 -0
  29. package/dist/prove.js.map +1 -0
  30. package/dist/rageExit.d.ts +5 -0
  31. package/dist/rageExit.js +146 -0
  32. package/dist/rageExit.js.map +1 -0
  33. package/dist/scan.d.ts +17 -0
  34. package/dist/scan.js +47 -0
  35. package/dist/scan.js.map +1 -0
  36. package/dist/seed.d.ts +8 -0
  37. package/dist/seed.js +35 -0
  38. package/dist/seed.js.map +1 -0
  39. package/dist/servers.d.ts +60 -0
  40. package/dist/servers.js +117 -0
  41. package/dist/servers.js.map +1 -0
  42. package/dist/services.d.ts +62 -0
  43. package/dist/services.js +128 -0
  44. package/dist/services.js.map +1 -0
  45. package/dist/spend.d.ts +151 -0
  46. package/dist/spend.js +745 -0
  47. package/dist/spend.js.map +1 -0
  48. package/dist/state.d.ts +174 -0
  49. package/dist/state.js +45 -0
  50. package/dist/state.js.map +1 -0
  51. package/dist/sync.d.ts +84 -0
  52. package/dist/sync.js +615 -0
  53. package/dist/sync.js.map +1 -0
  54. package/dist/tree.d.ts +19 -0
  55. package/dist/tree.js +63 -0
  56. package/dist/tree.js.map +1 -0
  57. package/dist/wallet.d.ts +301 -0
  58. package/dist/wallet.js +992 -0
  59. package/dist/wallet.js.map +1 -0
  60. package/package.json +60 -4
  61. package/src/burnWorker.ts +25 -0
  62. package/src/chain.ts +205 -0
  63. package/src/context.ts +84 -0
  64. package/src/core.ts +147 -0
  65. package/src/errors.ts +61 -0
  66. package/src/index.ts +41 -0
  67. package/src/network.ts +94 -0
  68. package/src/pos.ts +415 -0
  69. package/src/prove.ts +67 -0
  70. package/src/rageExit.ts +168 -0
  71. package/src/scan.ts +62 -0
  72. package/src/seed.ts +46 -0
  73. package/src/servers.ts +172 -0
  74. package/src/services.ts +184 -0
  75. package/src/snarkjs.d.ts +11 -0
  76. package/src/spend.ts +912 -0
  77. package/src/state.ts +211 -0
  78. package/src/sync.ts +698 -0
  79. package/src/tree.ts +108 -0
  80. package/src/wallet.ts +1297 -0
  81. package/wasm/zerc20_wallet_core.d.ts +176 -0
  82. package/wasm/zerc20_wallet_core.js +783 -0
  83. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
  84. package/wasm/zerc20_wallet_core_bg.wasm.d.ts +32 -0
@@ -0,0 +1,168 @@
1
+ // Rage exit: a burn deposit refunded to its sender with nothing but the chain and the indexer's public pages, for
2
+ // when the ASP, the attester or root proving stop. The ledger authenticates the deposit itself, by re-hashing the
3
+ // commitments from it up to a checkpoint; a long way is walked back one transaction of commitments at a time.
4
+
5
+ import { type Address, type Hex, decodeEventLog, numberToHex, parseAbi } from 'viem';
6
+ import { depositAddressAbi, ledgerAbi, verifierAbi } from './chain.js';
7
+ import { type ChainCtx, type Ctx, chainCtx, spendKey, walletSees } from './context.js';
8
+ import { core } from './core.js';
9
+ import { fail, guard } from './errors.js';
10
+ import { prove } from './prove.js';
11
+ import { PAGE } from './servers.js';
12
+ import type { DepositRecord } from './state.js';
13
+
14
+ /** Commitments per transaction: about a thousand fit in the 128 KB transaction size limit. */
15
+ const SEGMENT = 900;
16
+
17
+ const checkpointEvent = parseAbi(['event CheckpointRecorded(uint64 indexed index, uint256 hashChain)']);
18
+
19
+ type Fields = [bigint, bigint, bigint, bigint];
20
+
21
+ /** Commitments `[from, to)` and the hash chain before `from`, from the indexer's pages. */
22
+ async function commitments(c: ChainCtx, from: number, to: number): Promise<{ prev: Hex; list: Fields[] }> {
23
+ const list: Fields[] = [];
24
+ let prev: Hex | null = null;
25
+ for (let p = Math.floor(from / PAGE); p * PAGE < to; p++) {
26
+ const { hashChainBefore, entries } = await c.indexer.commitments(p);
27
+ let chain = hashChainBefore;
28
+ for (let j = 0; j < entries.length / 128; j++) {
29
+ const i = p * PAGE + j;
30
+ if (i >= to) break;
31
+ const fields = [0, 1, 2, 3].map((f) =>
32
+ BigInt(`0x${hexOf(entries.subarray(j * 128 + 32 * f, j * 128 + 32 * (f + 1)))}`),
33
+ ) as Fields;
34
+ if (i === from) prev = chain;
35
+ if (i >= from) list.push(fields);
36
+ chain = core.hashChain(
37
+ chain,
38
+ fields.map((x) => numberToHex(x, { size: 32 })),
39
+ ) as Hex;
40
+ }
41
+ }
42
+ if (prev === null || list.length !== to - from)
43
+ return fail('unavailable', `the indexer does not serve commitments ${from}..${to} yet`);
44
+ return { prev, list };
45
+ }
46
+
47
+ const hexOf = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
48
+
49
+ /** The first index after `index` the ledger has a checkpoint at: a proved root's size, else a fresh reservation. */
50
+ async function checkpointAfter(ctx: Ctx, c: ChainCtx, index: number, send: Sender): Promise<number> {
51
+ const ledger = await c.chain.ledger();
52
+ for (let p = 0; ; p++) {
53
+ const roots = await c.indexer.roots(p);
54
+ const after = roots.find((r) => r.size > index);
55
+ if (after) {
56
+ const [, exists] = await c.chain.client.readContract({
57
+ address: ledger,
58
+ abi: ledgerAbi,
59
+ functionName: 'hashChainAt',
60
+ args: [BigInt(after.size)],
61
+ });
62
+ if (exists) return after.size;
63
+ }
64
+ if (roots.length < PAGE) break;
65
+ }
66
+ const receipt = await send(ledger, ledgerAbi, 'reserveHashChain', []);
67
+ for (const log of receipt.logs) {
68
+ try {
69
+ const ev = decodeEventLog({ abi: checkpointEvent, data: log.data, topics: log.topics });
70
+ return Number(ev.args.index);
71
+ } catch {}
72
+ }
73
+ return fail('unavailable', 'reserveHashChain recorded no checkpoint');
74
+ }
75
+
76
+ type Sender = (
77
+ address: Address,
78
+ abi: readonly unknown[],
79
+ functionName: string,
80
+ args: unknown[],
81
+ ) => Promise<{ logs: { data: Hex; topics: [Hex, ...Hex[]] }[]; status: string }>;
82
+
83
+ /** Refunds the deposit at `index` of `record` to its sender, from the account's own wallet (it pays the gas). */
84
+ export async function rageExit(ctx: Ctx, record: DepositRecord, index: number): Promise<Hex[]> {
85
+ const c = chainCtx(ctx, record.chainId);
86
+ const found = record.found.find((f) => f.index === index) ?? fail('invalid', `no deposit at ${index}`);
87
+ if (found.spent) fail('invalid', 'the deposit is already spent');
88
+ const wc = ctx.walletClient;
89
+ const account = wc.account!;
90
+ if ((await wc.getChainId()) !== Number(record.chainId)) await guard(wc.switchChain({ id: Number(record.chainId) }));
91
+ const hashes: Hex[] = [];
92
+ const send: Sender = async (address, abi, functionName, args) => {
93
+ const hash = await guard(
94
+ wc.writeContract({ account, chain: null, address, abi: abi as never, functionName: functionName, args: args }),
95
+ );
96
+ hashes.push(hash);
97
+ const receipt = await c.chain.client.waitForTransactionReceipt({ hash });
98
+ if (receipt.status !== 'success') fail('unavailable', `${functionName} reverted`);
99
+ await walletSees(wc, hash);
100
+ return receipt as never;
101
+ };
102
+
103
+ let end = await checkpointAfter(ctx, c, index, send);
104
+ const { prev, list } = await commitments(c, index, end);
105
+ const ledger = await c.chain.ledger();
106
+ // walk a long way back first: each step records the chain before its segment as a new checkpoint
107
+ while (end - index > SEGMENT) {
108
+ const start = end - SEGMENT;
109
+ const before = list.slice(0, start - index).reduce<Hex>(
110
+ (chain, f) =>
111
+ core.hashChain(
112
+ chain,
113
+ f.map((x) => numberToHex(x, { size: 32 })),
114
+ ) as Hex,
115
+ prev,
116
+ );
117
+ await send(ledger, ledgerAbi, 'checkpointBefore', [BigInt(end), BigInt(before), list.slice(start - index)]);
118
+ end = start;
119
+ }
120
+ const segment = list.slice(0, end - index);
121
+ // checked first, so a wrong page from the indexer costs no gas
122
+ await c.chain.client.readContract({
123
+ address: ledger,
124
+ abi: ledgerAbi,
125
+ functionName: 'verifySegment',
126
+ args: [BigInt(end), BigInt(prev), segment],
127
+ });
128
+
129
+ const key = spendKey(ctx);
130
+ const { proof, signals } = await prove(ctx.network.circuits.rageexit, {
131
+ chain_id: `${record.chainId}`,
132
+ commitment_index: `${index}`,
133
+ burn_address: BigInt(record.address).toString(),
134
+ private_key: key.secret,
135
+ auth_key_hash: key.authKeyHash,
136
+ burn_salt: record.salt,
137
+ });
138
+ const nullifier = signals[0];
139
+ await send(c.config.verifier, verifierAbi, 'rageExit', [
140
+ { checkpointIndex: BigInt(end), prevHashChain: BigInt(prev), commitments: segment, nullifier, proof },
141
+ ]);
142
+ found.spent = true;
143
+ // a deposit address's refund lands on the address itself; forward it to the address's refund address: this account,
144
+ // or for a point of sale's sale the payer's or the merchant's. A point of sale registers its deposit addresses without
145
+ // telling this wallet, and a record restored from storage no longer knows its own, so the sender is checked on chain.
146
+ const token = (await c.chain.tokens()).find((t) => t.tokenId === found.tokenId)!;
147
+ // the deposit's commitment, which the ledger just authenticated: `tag, sender, burn address, amount`
148
+ const [, from, , amount] = list[0];
149
+ const sender = numberToHex(from, { size: 20 });
150
+ const refundTo = await c.chain.depositAddressRefund(sender, token.address, record.address);
151
+ // for every device of the wallet, written by the next sync; kept now, before the forward, which may fail
152
+ ctx.state.receipts.push({
153
+ kind: 'returned',
154
+ chainId: record.chainId,
155
+ tokenId: found.tokenId,
156
+ burnAddress: record.address.toLowerCase() as Hex,
157
+ index,
158
+ recipient: (refundTo ?? sender).toLowerCase() as Hex,
159
+ amount,
160
+ txHash: hashes.at(-1)!,
161
+ atMs: Date.now(),
162
+ unwritten: true,
163
+ });
164
+ await ctx.save();
165
+ if (refundTo) await send(sender, depositAddressAbi, 'refund', [token.address]);
166
+ await ctx.save();
167
+ return hashes;
168
+ }
package/src/scan.ts ADDED
@@ -0,0 +1,62 @@
1
+ // Finding a wallet's own entries in the indexer's append-only lists without naming them: download pages, match
2
+ // locally. One scanner serves both the 4-byte commitment tags and the 8-byte nullifier prefixes.
3
+ //
4
+ // Each watched key remembers how far it has been looked for, so a key learned late (a payment announced after
5
+ // its commitment landed) is looked for once over the range it missed, and everything else only over new
6
+ // entries. Pages below the final count never change, so re-reading them hits the HTTP cache or the CDN.
7
+
8
+ import { bytesToHex, type Hex } from 'viem';
9
+ import { PAGE } from './servers.js';
10
+
11
+ /** A key to look for: entries at or after `from`, and not yet looked at before `scannedTo`. */
12
+ export interface Watch {
13
+ key: Hex;
14
+ from: number;
15
+ /** Everything in `[from, scannedTo)` has been looked at. */
16
+ scannedTo: number;
17
+ }
18
+
19
+ export interface Hit {
20
+ key: Hex;
21
+ position: number;
22
+ /** Past the final count, so a reorg may still move or drop it. */
23
+ tentative: boolean;
24
+ }
25
+
26
+ /** Looks for `watches` (updated in place) in entries up to `final`, and, without recording that, in
27
+ * `[final, live)` too. `page(p)` returns page `p` of `width`-byte entries. */
28
+ export async function scan(
29
+ watches: Watch[],
30
+ width: number,
31
+ page: (p: number) => Promise<Uint8Array>,
32
+ final: number,
33
+ live = final,
34
+ ): Promise<Hit[]> {
35
+ for (const w of watches) w.scannedTo = Math.max(w.scannedTo, w.from);
36
+ const pending = watches.filter((w) => w.scannedTo < live);
37
+ if (pending.length === 0) return [];
38
+ const byKey = new Map<string, Watch[]>();
39
+ for (const w of pending) byKey.set(w.key, [...(byKey.get(w.key) ?? []), w]);
40
+ // a loop, not Math.min(...): spreading 100k+ watches overflows the stack
41
+ let start = live;
42
+ for (const w of pending) start = Math.min(start, w.scannedTo);
43
+ const hits: Hit[] = [];
44
+ for (let p = Math.floor(start / PAGE); p * PAGE < live; p++) {
45
+ const bytes = await page(p);
46
+ const first = p * PAGE;
47
+ const end = Math.min(first + bytes.length / width, live);
48
+ for (let i = Math.max(first, start); i < end; i++) {
49
+ const key = bytesToHex(bytes.subarray((i - first) * width, (i - first + 1) * width));
50
+ for (const w of byKey.get(key) ?? []) {
51
+ if (i >= w.scannedTo) hits.push({ key, position: i, tentative: i >= final });
52
+ }
53
+ }
54
+ // a short page means the indexer has less than `live` (a stale copy): stop, and look again next time
55
+ if (first + bytes.length / width < Math.min(first + PAGE, live)) {
56
+ for (const w of pending) w.scannedTo = Math.max(w.scannedTo, Math.min(final, first + bytes.length / width));
57
+ return hits;
58
+ }
59
+ }
60
+ for (const w of pending) w.scannedTo = Math.max(w.scannedTo, final);
61
+ return hits;
62
+ }
package/src/seed.ts ADDED
@@ -0,0 +1,46 @@
1
+ // A seed from the wallet's own signature, so the account alone restores it. It holds only for wallets whose
2
+ // signatures are deterministic (RFC 6979, as MetaMask's and hardware wallets' are): keep the fingerprint, and if a
3
+ // later derivation gives another one, the wallet signs differently each time and the seed must be backed up itself.
4
+ // Anything that gets the user to sign the same message gets the seed.
5
+
6
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
7
+ import { type Hex, type WalletClient, bytesToHex, hexToBytes, keccak256 } from 'viem';
8
+ import { fail, guard } from './errors.js';
9
+
10
+ const message = (address: Hex) =>
11
+ [
12
+ 'zERC20 wallet seed',
13
+ 'WARNING: signing this gives control of your private zERC20 funds.',
14
+ 'Sign it only in an app you trust to hold them.',
15
+ `address: ${address.toLowerCase()}`,
16
+ 'tag: zerc20/wallet/seed/v1',
17
+ ].join('\n');
18
+
19
+ /** The seed, a fingerprint to check later derivations against (store it; it reveals nothing about the seed), and the
20
+ * canonical signature it comes from, which proves the seed is this account's. */
21
+ export async function seedFromSignature(
22
+ walletClient: WalletClient,
23
+ expectedFingerprint?: Hex,
24
+ ): Promise<{ seed: Uint8Array; fingerprint: Hex; signature: Hex }> {
25
+ const account = walletClient.account ?? fail('invalid', 'the wallet client has no account');
26
+ const raw = hexToBytes(await guard(walletClient.signMessage({ account, message: message(account.address) })));
27
+ // canonical form: low s and v = 27/28, so equivalent signatures give one seed
28
+ let sig = secp256k1.Signature.fromBytes(raw.subarray(0, 64), 'compact');
29
+ let v = raw[64] >= 27 ? raw[64] - 27 : raw[64];
30
+ if (sig.hasHighS()) {
31
+ sig = new secp256k1.Signature(sig.r, secp256k1.Point.CURVE().n - sig.s);
32
+ v ^= 1;
33
+ }
34
+ const canonical = new Uint8Array([...sig.toBytes('compact'), 27 + v]);
35
+ const seed = hexToBytes(keccak256(canonical));
36
+ const fingerprint = keccak256(
37
+ new Uint8Array([...new TextEncoder().encode('zerc20/wallet/seed-fingerprint/v1'), ...seed]),
38
+ ).slice(0, 18) as Hex;
39
+ if (expectedFingerprint && expectedFingerprint !== fingerprint) {
40
+ fail(
41
+ 'invalid',
42
+ 'this wallet signs differently each time, so its signature cannot stand in for a seed: back the seed up instead',
43
+ );
44
+ }
45
+ return { seed, fingerprint, signature: bytesToHex(canonical) };
46
+ }
package/src/servers.ts ADDED
@@ -0,0 +1,172 @@
1
+ // The indexer and the ASP, read in whole pages that every wallet downloads alike: a wallet never names a
2
+ // leaf, an address or a nullifier to a server. Layouts are in backend/indexer/README.md and backend/asp/README.md.
3
+
4
+ import { type Hex, bytesToHex } from 'viem';
5
+ import { fail } from './errors.js';
6
+
7
+ export const PAGE = 4096;
8
+ const TIMEOUT_MS = 20_000;
9
+
10
+ async function fetchBytes(url: string): Promise<Uint8Array> {
11
+ let res: Response;
12
+ try {
13
+ res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
14
+ } catch (e) {
15
+ return fail('unavailable', `${url}: ${(e as Error).message}`);
16
+ }
17
+ if (!res.ok) return fail('unavailable', `${url}: HTTP ${res.status}`);
18
+ return new Uint8Array(await res.arrayBuffer());
19
+ }
20
+
21
+ async function fetchJson<T>(url: string): Promise<T> {
22
+ return JSON.parse(new TextDecoder().decode(await fetchBytes(url))) as T;
23
+ }
24
+
25
+ /** A tree's size and root, with the roots of its partial chunk and page (null where the size ends on a
26
+ * boundary): what a path against that root needs besides the wallet's own chunk. */
27
+ export interface RootEntry {
28
+ size: number;
29
+ root: Hex;
30
+ partialChunk: Hex | null;
31
+ partialPage: Hex | null;
32
+ }
33
+
34
+ const ZERO = `0x${'0'.repeat(64)}`;
35
+ const orNull = (h: Hex): Hex | null => (h === ZERO ? null : h);
36
+
37
+ /** Root entries back to back: size(8) ‖ root ‖ partial chunk ‖ partial page (zero: none) [‖ matches(1)]. */
38
+ function rootEntries(bytes: Uint8Array, width: number): (RootEntry & { matches: boolean })[] {
39
+ const out = [];
40
+ for (let at = 0; at + width <= bytes.length; at += width) {
41
+ const e = bytes.subarray(at, at + width);
42
+ out.push({
43
+ size: Number(new DataView(e.buffer, e.byteOffset).getBigUint64(0, false)),
44
+ root: bytesToHex(e.subarray(8, 40)),
45
+ partialChunk: orNull(bytesToHex(e.subarray(40, 72))),
46
+ partialPage: orNull(bytesToHex(e.subarray(72, 104))),
47
+ matches: width === 104 || e[104] === 1,
48
+ });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export interface IndexerStatus {
54
+ commitments: number;
55
+ commitmentsFinal: number;
56
+ nullifiers: number;
57
+ nullifiersFinal: number;
58
+ /** The latest proved root the indexer derives itself, before any forged one: what a proof uses. */
59
+ latestRoot: RootEntry | null;
60
+ /** The first proved root since the protocol last recovered that the indexer does not derive: a forged one. No root
61
+ * is proved after it until the protocol recovers. */
62
+ mismatch: { size: number; root: Hex } | null;
63
+ /** The newest block whose entries cannot reorg, the one the final counts stand at. */
64
+ finalBlock: number | null;
65
+ }
66
+
67
+ interface StatusJson {
68
+ commitments: number;
69
+ commitments_final: number;
70
+ nullifiers: number;
71
+ nullifiers_final: number;
72
+ latest_root: {
73
+ index: number;
74
+ root: Hex;
75
+ matches: boolean;
76
+ partial_chunk_root: Hex | null;
77
+ partial_page_root: Hex | null;
78
+ } | null;
79
+ mismatch: { index: number; root: Hex } | null;
80
+ indexer: { final_block: number | null };
81
+ }
82
+
83
+ export class Indexer {
84
+ constructor(readonly url: string) {}
85
+
86
+ async status(): Promise<IndexerStatus> {
87
+ const s = await fetchJson<StatusJson>(`${this.url}/status`);
88
+ const r = s.latest_root;
89
+ return {
90
+ commitments: s.commitments,
91
+ commitmentsFinal: s.commitments_final,
92
+ nullifiers: s.nullifiers,
93
+ nullifiersFinal: s.nullifiers_final,
94
+ latestRoot:
95
+ r && r.matches
96
+ ? { size: r.index, root: r.root, partialChunk: r.partial_chunk_root, partialPage: r.partial_page_root }
97
+ : null,
98
+ mismatch: s.mismatch ? { size: s.mismatch.index, root: s.mismatch.root } : null,
99
+ finalBlock: s.indexer.final_block,
100
+ };
101
+ }
102
+
103
+ /** Page `p` of a list of fixed-size entries: 4096 entries, fewer while it fills. */
104
+ page(list: 'tags' | 'nullifiers' | 'blocks' | 'times' | 'chunk-roots' | 'marks', p: number): Promise<Uint8Array> {
105
+ return fetchBytes(`${this.url}/${list}/${p}`);
106
+ }
107
+
108
+ /** Page `p` of commitments: the ledger's hash chain before it (32 bytes), then 4 fields of 32 bytes each. */
109
+ async commitments(p: number): Promise<{ hashChainBefore: Hex; entries: Uint8Array }> {
110
+ const body = await fetchBytes(`${this.url}/commitments/${p}`);
111
+ return { hashChainBefore: bytesToHex(body.subarray(0, 32)), entries: body.subarray(32) };
112
+ }
113
+
114
+ pageRoots(): Promise<Uint8Array> {
115
+ return fetchBytes(`${this.url}/page-roots`);
116
+ }
117
+
118
+ /** Page `p` of proved roots, in the order they were proved. */
119
+ async roots(p: number): Promise<(RootEntry & { matches: boolean })[]> {
120
+ return rootEntries(await fetchBytes(`${this.url}/roots/${p}`), 105);
121
+ }
122
+ }
123
+
124
+ export interface AspStatus {
125
+ decided: number;
126
+ latestRoot: RootEntry | null;
127
+ /** How old a deposit's block must be before it is screened. */
128
+ delaySecs: number;
129
+ }
130
+
131
+ export class Asp {
132
+ constructor(readonly url: string) {}
133
+
134
+ async status(): Promise<AspStatus> {
135
+ const s = await fetchJson<{ decided: number; latest_root: StatusJson['latest_root']; delay_secs: number }>(
136
+ `${this.url}/status`,
137
+ );
138
+ const r = s.latest_root;
139
+ return {
140
+ decided: s.decided,
141
+ latestRoot: r
142
+ ? { size: r.index, root: r.root, partialChunk: r.partial_chunk_root, partialPage: r.partial_page_root }
143
+ : null,
144
+ delaySecs: s.delay_secs,
145
+ };
146
+ }
147
+
148
+ /** 512 bytes: position `c * 4096 + j` is approved if bit `j % 8` of byte `j / 8` is set. */
149
+ bitmap(c: number): Promise<Uint8Array> {
150
+ return fetchBytes(`${this.url}/bitmap/${c}`);
151
+ }
152
+
153
+ chunkRoots(p: number): Promise<Uint8Array> {
154
+ return fetchBytes(`${this.url}/chunk-roots/${p}`);
155
+ }
156
+
157
+ pageRoots(): Promise<Uint8Array> {
158
+ return fetchBytes(`${this.url}/page-roots`);
159
+ }
160
+
161
+ /** The recorded root whose value is `root`, searched from the newest page back. */
162
+ async rootEntry(root: Hex): Promise<RootEntry | null> {
163
+ const latest = (await this.status()).latestRoot;
164
+ if (latest?.root === root) return latest;
165
+ for (let p = 0; ; p++) {
166
+ const entries = rootEntries(await fetchBytes(`${this.url}/roots/${p}`), 104);
167
+ const hit = entries.find((e) => e.root === root);
168
+ if (hit) return hit;
169
+ if (entries.length < PAGE) return null;
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,184 @@
1
+ // The attester, the merge prover and the relayers. Each sees only what it must: the attester's and the prover's hosts
2
+ // see the public statement and a witness sealed to their enclave, the transact-relayer a finished request, the
3
+ // deposit-relayer a burn address.
4
+
5
+ import { type Address, type Hex, bytesToHex, hexToBytes } from 'viem';
6
+ import type { Chain } from './chain.js';
7
+ import { core } from './core.js';
8
+ import { WalletError, fail } from './errors.js';
9
+ import { type AttesterTrust, type ChainConfig, LOCAL_CHAIN_ID, type Network } from './network.js';
10
+
11
+ /** A transact witness is sealed padded to this, and a merge's to `MERGE_PAD` per input, so its size does not leak the
12
+ * amounts' digits (`backend/attester/README.md`). */
13
+ const WITNESS_PAD = 16 * 1024;
14
+ const MERGE_PAD = 8 * 1024;
15
+
16
+ const json = (v: unknown): string => JSON.stringify(v, (_, x: unknown) => (typeof x === 'bigint' ? x.toString() : x));
17
+
18
+ async function call<T>(
19
+ url: string,
20
+ init?: RequestInit & { timeoutMs?: number },
21
+ ): Promise<{ status: number; body: T | string }> {
22
+ let res: Response;
23
+ try {
24
+ res = await fetch(url, { ...init, signal: AbortSignal.timeout(init?.timeoutMs ?? 30_000) });
25
+ } catch (e) {
26
+ return fail('unavailable', `${url}: ${(e as Error).message}`);
27
+ }
28
+ const text = await res.text();
29
+ if (!res.ok) return { status: res.status, body: text };
30
+ return { status: res.status, body: JSON.parse(text) as T };
31
+ }
32
+
33
+ const post = <T>(url: string, body: unknown, timeoutMs?: number) =>
34
+ call<T>(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: json(body), timeoutMs });
35
+
36
+ const base64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
37
+
38
+ /** The key the attester's enclave opens witnesses with, once its attestation checks out. */
39
+ export const attesterKey = (c: ChainConfig): Promise<Uint8Array> =>
40
+ enclaveKey(c, `${c.attester}/attestation`, c.attesterTrust, 'attester');
41
+
42
+ /** The key an enclave (the attester's, the merge prover's) opens what is sealed to it with, from `url`, once its
43
+ * attestation document checks out against `trust`. */
44
+ export async function enclaveKey(c: ChainConfig, url: string, trust: AttesterTrust, what: string): Promise<Uint8Array> {
45
+ const r = await call<{ publicKey: Hex; attestation: string | null }>(url);
46
+ if (typeof r.body === 'string') return fail('unavailable', `${what}: ${r.body}`);
47
+ if ('development' in trust) {
48
+ // its host would read everything sealed to it, every input's opening included: a local chain only, whatever the
49
+ // flag says
50
+ if (trust.development !== true || c.chainId !== LOCAL_CHAIN_ID)
51
+ return fail('attester-unverified', `a development ${what} off the local chain`);
52
+ return hexToBytes(r.body.publicKey);
53
+ }
54
+ if (!r.body.attestation) return fail('attester-unverified', `the ${what} sent no attestation document`);
55
+ try {
56
+ // from the document, never the `publicKey` beside it
57
+ return core.verifyAttestation(
58
+ base64(r.body.attestation),
59
+ trust.rootSha256,
60
+ trust.pcr0,
61
+ BigInt(Math.floor(Date.now() / 1000)),
62
+ );
63
+ } catch (e) {
64
+ return fail('attester-unverified', `the ${what}'s enclave does not check out: ${(e as Error).message}`);
65
+ }
66
+ }
67
+
68
+ /** The attester's signature over `statement`, once its enclave has checked `witness` (JSON): a JoinSplit's, or with
69
+ * `inputs`, a merge's of that many inputs. */
70
+ export async function attest(c: ChainConfig, statement: object, witness: string, inputs?: number): Promise<Hex> {
71
+ const [path, pad] = inputs === undefined ? ['transact', WITNESS_PAD] : ['merge', MERGE_PAD * inputs];
72
+ for (let attempt = 0; ; attempt++) {
73
+ const sealed = bytesToHex(core.sealWitness(await attesterKey(c), witness, pad));
74
+ const r = await post<{ attestation: Hex }>(`${c.attester}/${path}`, { statement, witness: sealed }, 60_000);
75
+ if (typeof r.body !== 'string') return r.body.attestation;
76
+ // the enclave restarted with a new key between the fetch and the request
77
+ if (r.body.includes('fetch /attestation again') && attempt === 0) continue;
78
+ // a root its indexer has not derived yet is a 400 too, but one that passes
79
+ const later = r.status !== 400 || r.body.includes('retry once');
80
+ return fail(later ? 'unavailable' : 'invalid', `attester: ${r.body}`);
81
+ }
82
+ }
83
+
84
+ export interface RelayerFees {
85
+ relayer: Address;
86
+ /** The relayer's note key, which owns its fee notes whatever their token. */
87
+ publicKey: Hex;
88
+ fees: Map<number, { fee: bigint }>;
89
+ }
90
+
91
+ export async function relayerFees(c: ChainConfig): Promise<RelayerFees> {
92
+ const r = await call<{ relayer: Address; publicKey: Hex; fees: Record<string, { fee: Hex }> }>(
93
+ `${c.transactRelayer}/fee`,
94
+ );
95
+ if (typeof r.body === 'string') return fail('unavailable', `transact-relayer: ${r.body}`);
96
+ const fees = new Map(Object.entries(r.body.fees).map(([t, f]) => [Number(t), { fee: BigInt(f.fee) }]));
97
+ return { relayer: r.body.relayer, publicKey: r.body.publicKey, fees };
98
+ }
99
+
100
+ export interface Relayed {
101
+ txHash: Hex;
102
+ noteIndex1: number | null;
103
+ noteIndex2: number | null;
104
+ }
105
+
106
+ /** What `relay` reports when it cannot say whether the transaction landed. */
107
+ export class RelayUnknown extends WalletError {}
108
+
109
+ /** Has the relayer submit `request`. A 400 means the call reverts, which includes "already landed". */
110
+ export const relay = (
111
+ c: ChainConfig,
112
+ request: object,
113
+ feeNote?: { value: bigint; salt: Hex },
114
+ ): Promise<Relayed | { reverted: string }> => relayed<Relayed>(`${c.transactRelayer}/transact`, { request, feeNote });
115
+
116
+ /** Has the relayer submit a merge's `request`, as `relay` does a JoinSplit's. Answers the merged note's index. */
117
+ export const relayMerge = (
118
+ c: ChainConfig,
119
+ request: object,
120
+ ): Promise<{ txHash: Hex; noteIndex: number | null } | { reverted: string }> =>
121
+ relayed(`${c.transactRelayer}/merge`, { request });
122
+
123
+ async function relayed<T>(url: string, body: object): Promise<T | { reverted: string }> {
124
+ // the relayer answers once mined, after fee bumping if need be
125
+ const r = await post<T>(url, body, 15 * 60_000).catch((e: unknown) => {
126
+ throw new RelayUnknown('unavailable', `transact-relayer: ${(e as Error).message}`);
127
+ });
128
+ if (typeof r.body !== 'string') return r.body;
129
+ if (r.status === 400) return { reverted: r.body };
130
+ throw new RelayUnknown('unavailable', `transact-relayer: HTTP ${r.status}: ${r.body}`);
131
+ }
132
+
133
+ export type MergeProver = NonNullable<Network['mergeProver']>;
134
+
135
+ /** Sends a merge's witness (JSON, of `inputs` inputs) sealed to the merge prover's enclave, to prove `statement` (the
136
+ * attester's `Merge`). Proving takes minutes: `proofOf` tells when it is done. */
137
+ export async function requestProof(
138
+ c: ChainConfig,
139
+ p: MergeProver,
140
+ statement: object,
141
+ witness: string,
142
+ inputs: number,
143
+ ): Promise<void> {
144
+ const key = await enclaveKey(c, `${p.url}/enclave`, p.trust, 'merge prover');
145
+ const sealed = bytesToHex(core.sealMergeWitness(key, witness, MERGE_PAD * inputs));
146
+ const r = await post<{ status: string }>(`${p.url}/proofs`, { statement, witness: sealed }, 60_000);
147
+ if (typeof r.body === 'string') fail(r.status === 400 ? 'invalid' : 'unavailable', `merge prover: ${r.body}`);
148
+ }
149
+
150
+ /** Where the proof of the merge that outputs `noteHash` stands: `null` when the prover does not have it (it never
151
+ * did, or lost it in a restart), so it is to be sent again. `proof` is the ABI-encoded `NovaDeciderProof`. */
152
+ export async function proofOf(
153
+ p: MergeProver,
154
+ noteHash: Hex,
155
+ ): Promise<{ status: 'queued' | 'proving' | 'done' | 'failed'; proof?: Hex; error?: string } | null> {
156
+ const r = await call<{ status: 'queued' | 'proving' | 'done' | 'failed'; proof?: Hex; error?: string }>(
157
+ `${p.url}/proofs/${noteHash}`,
158
+ );
159
+ if (r.status === 404) return null;
160
+ if (typeof r.body === 'string') return fail('unavailable', `merge prover: ${r.body}`);
161
+ return r.body;
162
+ }
163
+
164
+ /** A deposit address that flushes whatever reaches it into `burnAddress`, less `fee` paid to `feeRecipient` each time;
165
+ * refunds go to `refundAddress`. What the relayer says is checked against the factory: an address of its own would take
166
+ * the funds, and the fee it names is the one the address takes. */
167
+ export async function registerDeposit(
168
+ chain: Chain,
169
+ token: Address,
170
+ burnAddress: Hex,
171
+ refundAddress: Address,
172
+ ): Promise<{ depositAddress: Address; feeRecipient: Address; fee: bigint }> {
173
+ const r = await post<{ depositAddress: Address; feeRecipient: Address; fee: Hex }>(
174
+ `${chain.config.depositRelayer}/register`,
175
+ { token, burnAddress, refundAddress },
176
+ );
177
+ if (typeof r.body === 'string') return fail('unavailable', `deposit-relayer: ${r.body}`);
178
+ const { depositAddress, feeRecipient } = r.body;
179
+ const fee = BigInt(r.body.fee);
180
+ const expected = await chain.predictDepositAddress(token, burnAddress, refundAddress, feeRecipient, fee);
181
+ if (depositAddress.toLowerCase() !== expected.toLowerCase())
182
+ fail('indexer-mismatch', `the deposit relayer gave ${depositAddress}, not the factory's ${expected}`);
183
+ return { depositAddress, feeRecipient, fee };
184
+ }
@@ -0,0 +1,11 @@
1
+ declare module 'snarkjs' {
2
+ export interface Groth16Proof {
3
+ pi_a: string[];
4
+ pi_b: string[][];
5
+ pi_c: string[];
6
+ }
7
+ type File = string | { type: 'mem'; data: Uint8Array };
8
+ export const groth16: {
9
+ fullProve(input: object, wasm: File, zkey: File): Promise<{ proof: Groth16Proof; publicSignals: string[] }>;
10
+ };
11
+ }