@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.
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 +359 -0
  7. package/dist/chain.js +155 -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 +38 -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 +131 -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 +303 -0
  58. package/dist/wallet.js +1002 -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 +228 -0
  63. package/src/context.ts +84 -0
  64. package/src/core.ts +147 -0
  65. package/src/errors.ts +63 -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 +195 -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 +1316 -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
package/src/errors.ts ADDED
@@ -0,0 +1,63 @@
1
+ // What can go wrong, by code, so that an app can tell the user what to do.
2
+
3
+ import { BaseError, UserRejectedRequestError } from 'viem';
4
+
5
+ export type ErrorCode =
6
+ /** Not enough value for the amount and its fees, even counting what is still settling. */
7
+ | 'insufficient-funds'
8
+ /** No single JoinSplit pays it, but merging the spendable notes and deposits first would: `consolidate`, then pay.
9
+ * `fees({ kind: 'consolidate' })` tells how many rounds that takes. */
10
+ | 'needs-consolidation'
11
+ /** What is spendable now does not pay it, but notes or deposits are still settling (screening, awaiting
12
+ * a root, or the change of an operation in flight): pay again once `balances()` shows them spendable. */
13
+ | 'funds-settling'
14
+ /** The fees are above the `maxFee` the caller allowed. */
15
+ | 'fee-too-high'
16
+ /** The address never registered for private payments: pay it publicly or ask for a receive code. */
17
+ | 'recipient-not-registered'
18
+ /** The withdrawal's recipient (or a merge's fee recipient) fails the attester's AML screening: withdraw elsewhere. */
19
+ | 'recipient-refused'
20
+ /** A contract account: it cannot sign the messages a wallet needs. */
21
+ | 'unsupported-account'
22
+ /** The user declined a signature or a transaction in their wallet. */
23
+ | 'user-rejected'
24
+ /** The wallet is open in another tab or window: close it there (`close()`), then open it here. */
25
+ | 'wallet-in-use'
26
+ /** This wallet object was closed (`close()`): open the wallet again to go on. */
27
+ | 'wallet-closed'
28
+ /** A server answered something that does not match the chain. */
29
+ | 'indexer-mismatch'
30
+ /** The attester's (or the merge prover's) enclave did not prove it is the published release. */
31
+ | 'attester-unverified'
32
+ /** A service did not answer, or refused. The operation can be resumed. */
33
+ | 'unavailable'
34
+ /** Bad input from the caller. */
35
+ | 'invalid';
36
+
37
+ export class WalletError extends Error {
38
+ constructor(
39
+ readonly code: ErrorCode,
40
+ message: string,
41
+ ) {
42
+ super(message);
43
+ this.name = 'WalletError';
44
+ }
45
+ }
46
+
47
+ export const fail = (code: ErrorCode, message: string): never => {
48
+ throw new WalletError(code, message);
49
+ };
50
+
51
+ const declined = (e: unknown) =>
52
+ e instanceof UserRejectedRequestError || (e as { code?: number } | null)?.code === 4001;
53
+
54
+ /** Maps a declined wallet prompt to `user-rejected`, also where viem wraps it (a transaction's, in an execution error). */
55
+ export async function guard<T>(p: Promise<T>): Promise<T> {
56
+ try {
57
+ return await p;
58
+ } catch (e) {
59
+ if (declined(e) || (e instanceof BaseError && e.walk(declined)))
60
+ throw new WalletError('user-rejected', 'declined in the wallet');
61
+ throw e;
62
+ }
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ // @zerc20/wallet-sdk: a zERC20 wallet for browsers and Node.
2
+ //
3
+ // const wallet = await Wallet.open({ network, walletClient, seed, persist });
4
+ // await wallet.sync(); // or wallet.autoSync(render)
5
+ // wallet.balances(); // spendable, and what is pending and why
6
+ // await (await wallet.deposit({ token, amount })).wait();
7
+ // const q = await wallet.fees({ kind: 'send', token, amount });
8
+ // await (await wallet.send({ to: '0x…', token, amount, maxFee: q.fee })).wait();
9
+ //
10
+ // Nothing secret leaves the device unencrypted: JoinSplit proofs are made here, a merge's by the merge prover, and the
11
+ // attester and the prover get the witness sealed to an enclave whose measurement the wallet checks. Servers are only
12
+ // ever asked for what every wallet downloads.
13
+
14
+ export { Chain, type Referrer, type Token, MAX_REFERRAL_BPS, NATIVE, UNWRAP_INTENT } from './chain.js';
15
+ export { type ErrorCode, WalletError } from './errors.js';
16
+ export {
17
+ type AttesterTrust,
18
+ type ChainConfig,
19
+ type Circuit,
20
+ type Network,
21
+ NITRO_ROOT_SHA256,
22
+ checkNetwork,
23
+ networkFromJson,
24
+ } from './network.js';
25
+ export { type Payment, PointOfSale, type PosToken, type Sale, type SaleStatus } from './pos.js';
26
+ export { seedFromSignature } from './seed.js';
27
+ export {
28
+ type Activity,
29
+ type Balance,
30
+ type CoinInfo,
31
+ type CoinState,
32
+ type DepositInfo,
33
+ type DepositStep,
34
+ type FeeQuote,
35
+ type OpenOptions,
36
+ type Operation,
37
+ type OperationKind,
38
+ type OperationStatus,
39
+ type PendingReason,
40
+ Wallet,
41
+ } from './wallet.js';
package/src/network.ts ADDED
@@ -0,0 +1,94 @@
1
+ // Where a wallet finds everything: per chain, its contracts, servers and artifacts. Apps pass one of these to
2
+ // `Wallet.open`; nothing here is secret.
3
+
4
+ import type { Address } from 'viem';
5
+ import { fail } from './errors.js';
6
+
7
+ /** A proving key set: the circuit's wasm and zkey, pinned by SHA-256 (from the setup's `setup.json`). */
8
+ export interface Circuit {
9
+ wasm: string;
10
+ zkey: string;
11
+ wasmSha256: string;
12
+ zkeySha256: string;
13
+ }
14
+
15
+ /** How to trust an enclave's key: the attester's, or the merge prover's. */
16
+ export type AttesterTrust =
17
+ /** A Nitro Enclave: the document must chain to the root with this SHA-256 and measure this PCR0 (hex). */
18
+ | { pcr0: string; rootSha256: string }
19
+ /** A development attester, which has no document: its key is taken at its word. Never on a real network. */
20
+ | { development: true };
21
+
22
+ /** The AWS Nitro Enclaves root certificate (G1), SHA-256 over its DER. */
23
+ export const NITRO_ROOT_SHA256 = '641a0321a3e244efe456463195d606317ed7cdcc3c1756e09893f3c68f79bb5b';
24
+
25
+ export interface ChainConfig {
26
+ chainId: bigint;
27
+ /** Used for every read and for waiting on receipts, never the wallet's own RPC. */
28
+ rpcUrl: string;
29
+ verifier: Address;
30
+ depositAddressFactory: Address;
31
+ indexer: string;
32
+ asp: string;
33
+ attester: string;
34
+ attesterTrust: AttesterTrust;
35
+ transactRelayer: string;
36
+ depositRelayer: string;
37
+ /** The native currency, which its native zERC20 is named after; ETH if absent. For a chain an app does not know. */
38
+ nativeCurrency?: { name: string; symbol: string };
39
+ }
40
+
41
+ export interface Network {
42
+ name: string;
43
+ chains: ChainConfig[];
44
+ /** The note storage canister on the Internet Computer. */
45
+ storage: { host: string; canisterId: string };
46
+ circuits: { joinsplit: Circuit; rageexit: Circuit };
47
+ /** The merge prover (`backend/merge-prover`), one shared by every chain, and how to trust its enclave, which sees each
48
+ * merge's witness while it proves it. Without it the wallet cannot consolidate. */
49
+ mergeProver?: { url: string; trust: AttesterTrust };
50
+ }
51
+
52
+ /** A `Network` from JSON, where chain ids are strings (JSON has no bigints), as scripts/dev-stack.sh writes it. */
53
+ export function networkFromJson(json: string): Network {
54
+ const n = JSON.parse(json) as Network & { chains: (ChainConfig & { chainId: string })[] };
55
+ return { ...n, chains: n.chains.map((c) => ({ ...c, chainId: BigInt(c.chainId) })) };
56
+ }
57
+
58
+ /** The local chain development settings are for. */
59
+ export const LOCAL_CHAIN_ID = 31337n;
60
+
61
+ const local = (url: string) => ['localhost', '127.0.0.1', '[::1]'].includes(new URL(url).hostname);
62
+
63
+ /** Throws on settings that only a local development network may have: servers over plain HTTP (anyone on the path
64
+ * could swap a deposit address or a fee), a development attester or merge prover (its host would read every witness,
65
+ * the openings of what it spends included), or a Nitro trust that does not pin the AWS root and a real enclave. */
66
+ export function checkNetwork(n: Network): void {
67
+ const bad = (what: string) => fail('invalid', `network ${n.name}: ${what}`);
68
+ const servers = (urls: string[]) => {
69
+ for (const u of urls) if (new URL(u).protocol !== 'https:' && !local(u)) bad(`${u} is not https`);
70
+ };
71
+ servers([n.storage.host]);
72
+ // artifacts are pinned by SHA-256, and Node reads them from disk
73
+ for (const c of [n.circuits.joinsplit, n.circuits.rageexit]) {
74
+ for (const u of [c.wasm, c.zkey]) if (!u.startsWith('file:')) servers([u]);
75
+ }
76
+ const trusted = (chains: ChainConfig[], what: string, t: AttesterTrust) => {
77
+ if ('development' in t) {
78
+ for (const c of chains) if (c.chainId !== LOCAL_CHAIN_ID) bad(`a development ${what} on chain ${c.chainId}`);
79
+ } else {
80
+ if (t.rootSha256.toLowerCase() !== NITRO_ROOT_SHA256) bad(`the ${what} trust does not pin the AWS Nitro root`);
81
+ // a debug enclave measures all zeros
82
+ const pcr0 = t.pcr0.replace(/^0x/i, '');
83
+ if (!/^[0-9a-f]{96}$/i.test(pcr0) || /^0+$/.test(pcr0)) bad(`PCR0 ${t.pcr0} is not a release enclave's`);
84
+ }
85
+ };
86
+ for (const c of n.chains) {
87
+ servers([c.rpcUrl, c.indexer, c.asp, c.attester, c.transactRelayer, c.depositRelayer]);
88
+ trusted([c], 'attester', c.attesterTrust);
89
+ }
90
+ if (n.mergeProver) {
91
+ servers([n.mergeProver.url]);
92
+ trusted(n.chains, 'merge prover', n.mergeProver.trust);
93
+ }
94
+ }
package/src/pos.ts ADDED
@@ -0,0 +1,415 @@
1
+ // A point of sale with no key: it makes burn addresses for a merchant's wallet in the lane the wallet gave it
2
+ // (`Wallet.posKey`), records them there ahead of need, hands out one per sale, and follows what reaches each from what
3
+ // every wallet downloads. Stolen, it gives away which addresses are the merchant's, and nothing else: the merchant's
4
+ // wallet alone spends what they receive, merges it and withdraws it.
5
+
6
+ import { BURN_BLOCK, CanisterClient, lookupRecipient, writePosBlock } from '@zerc20/storage-sdk';
7
+ import {
8
+ type Address,
9
+ type Hex,
10
+ bytesToHex,
11
+ getAddress,
12
+ hexToBytes,
13
+ isAddress,
14
+ isHex,
15
+ numberToHex,
16
+ zeroAddress,
17
+ } from 'viem';
18
+ import { Chain, type Token } from './chain.js';
19
+ import { core, initCore, posBurnAddress } from './core.js';
20
+ import { fail } from './errors.js';
21
+ import { type ChainConfig, type Network, checkNetwork } from './network.js';
22
+ import { scan } from './scan.js';
23
+ import { Asp, Indexer } from './servers.js';
24
+ import { registerDeposit } from './services.js';
25
+ import type { DepositRecord } from './state.js';
26
+ import { applyHits, commitmentFields, commitmentTx, dropReorged, readDecisions, readTransfers } from './sync.js';
27
+
28
+ /** The longest a refill waits once a token's addresses run low: a write at a random time after the sale that drew
29
+ * them down, not right after it, so that the write's time does not mark that sale. */
30
+ const REFILL_WITHIN_MS = 10 * 60_000;
31
+
32
+ /** What a point of sale needs of a token: where it is, and its id for the burn addresses' derivation and tags. */
33
+ export type PosToken = Pick<Token, 'chainId' | 'tokenId' | 'address'>;
34
+
35
+ export interface Sale {
36
+ /** Its burn address, which no other sale has. */
37
+ id: Address;
38
+ chainId: bigint;
39
+ tokenId: number;
40
+ token: Address;
41
+ /** Paid in the token by a transfer here. */
42
+ burnAddress: Address;
43
+ /** Paid in the underlying asset here, which the deposit relayer then wraps into the burn address: once one is
44
+ * registered (`registerDeposit`). */
45
+ depositAddress: Address | null;
46
+ /** What the deposit relayer takes out of each payment to `depositAddress`: ask for `amount` plus it. */
47
+ depositFee: bigint | null;
48
+ /** Where `depositAddress` sends back what it cannot wrap, and what a rage exit returns: the payer's, or the
49
+ * merchant's. On chain for anyone to read once the address is first flushed. */
50
+ refundTo: Address | null;
51
+ /** What was asked, if anything: the point of sale's alone, never sent anywhere. */
52
+ amount: bigint | null;
53
+ createdMs: number;
54
+ }
55
+
56
+ export interface Payment {
57
+ amount: bigint;
58
+ /** Who paid the burn address: the payer, or for a payment through `depositAddress` the deposit address itself. */
59
+ sender: Address;
60
+ /** `confirming` until final (a reorg may still drop it), then `screening` until the ASP decides. `rejected` goes back
61
+ * to its sender by the merchant's rage exit, or to `refundTo` from a deposit address. */
62
+ state: 'confirming' | 'screening' | 'approved' | 'rejected';
63
+ /** The transaction that paid the burn address, once the chain shows it: the payer's, or for a payment through
64
+ * `depositAddress` the deposit relayer's. */
65
+ txHash: Hex | null;
66
+ }
67
+
68
+ export interface SaleStatus {
69
+ sale: Sale;
70
+ payments: Payment[];
71
+ /** What is final: every payment but the confirming ones. */
72
+ received: bigint;
73
+ }
74
+
75
+ type Watched = Pick<DepositRecord, 'address' | 'watches' | 'found'>;
76
+
77
+ interface Connection {
78
+ merchant: Address;
79
+ /** The zERC20 public key the merchant registered in storage, which its burn addresses are under. */
80
+ publicKey: Hex;
81
+ laneKey: Hex;
82
+ /** Per chain, the lane's first slot not known to be taken. */
83
+ slots: Record<string, number>;
84
+ /** Per `chainId:tokenId`, the next counter to derive, and the addresses recorded in the lane that no sale has had. */
85
+ pools: Record<string, { next: number; ready: Address[] }>;
86
+ }
87
+
88
+ interface Saved {
89
+ connection: Connection | null;
90
+ sales: Sale[];
91
+ /** What reached each sale's address, as a wallet's deposits keep it, by sale id. */
92
+ seen: Record<Address, Watched>;
93
+ /** Each payment's transaction, by `chainId:index`. */
94
+ txs: Record<string, Hex>;
95
+ }
96
+
97
+ /** JSON with bigints, which `JSON` itself has no form for. */
98
+ const encode = (v: unknown) =>
99
+ new TextEncoder().encode(JSON.stringify(v, (_, x: unknown) => (typeof x === 'bigint' ? { $n: `${x}` } : x)));
100
+ const decode = (b: Uint8Array): Saved =>
101
+ JSON.parse(new TextDecoder().decode(b), (_, x: { $n?: string }) =>
102
+ x && typeof x === 'object' && typeof x.$n === 'string' ? BigInt(x.$n) : x,
103
+ ) as Saved;
104
+
105
+ const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
106
+ const pairOf = (token: PosToken) => `${token.chainId}:${token.tokenId}`;
107
+
108
+ export class PointOfSale {
109
+ readonly #network: Network;
110
+ readonly #state: Saved;
111
+ readonly #persist: { save(bytes: Uint8Array): Promise<void> };
112
+ readonly #refillWithinMs: number;
113
+ readonly #chains = new Map<bigint, Chain>();
114
+ /** The refill running for each token, and the one waiting to. */
115
+ readonly #fills = new Map<string, Promise<void>>();
116
+ readonly #timers = new Map<string, ReturnType<typeof setTimeout>>();
117
+ #storage: Promise<CanisterClient> | undefined;
118
+ #queue: Promise<unknown> = Promise.resolve();
119
+ #saving: Promise<unknown> = Promise.resolve();
120
+
121
+ private constructor(
122
+ network: Network,
123
+ state: Saved,
124
+ persist: { save(bytes: Uint8Array): Promise<void> },
125
+ refillWithinMs: number,
126
+ ) {
127
+ this.#network = network;
128
+ this.#state = state;
129
+ this.#persist = persist;
130
+ this.#refillWithinMs = refillWithinMs;
131
+ }
132
+
133
+ /** Opens the point of sale's saved state. It keeps which addresses it handed out: lose that, and it may hand one out
134
+ * twice. One state, one point of sale: two open on it would hand out the same addresses. */
135
+ static async open(opts: {
136
+ network: Network;
137
+ persist: { load(): Promise<Uint8Array | null | undefined>; save(bytes: Uint8Array): Promise<void> };
138
+ refillWithinMs?: number;
139
+ }): Promise<PointOfSale> {
140
+ checkNetwork(opts.network);
141
+ await initCore();
142
+ const saved = await opts.persist.load();
143
+ const state: Saved = saved?.length ? decode(saved) : { connection: null, sales: [], seen: {}, txs: {} };
144
+ return new PointOfSale(opts.network, state, opts.persist, opts.refillWithinMs ?? REFILL_WITHIN_MS);
145
+ }
146
+
147
+ get connected(): boolean {
148
+ return this.#state.connection !== null;
149
+ }
150
+
151
+ /** Takes the key `merchant`'s wallet made for it (`Wallet.posKey`), and the public key `merchant` registered in
152
+ * storage, which every address it makes is under: whoever hands it a key cannot point sales at a key of its own. The
153
+ * same key again changes nothing. Another one starts over in that lane; the sales made so far are still followed. */
154
+ connect(merchant: Address, laneKey: Hex): Promise<void> {
155
+ return this.#exclusive(async () => {
156
+ if (!isAddress(merchant, { strict: false })) fail('invalid', `${String(merchant)} is not an address`);
157
+ if (!isHex(laneKey) || laneKey.length !== 66) fail('invalid', 'the key is 32 bytes in hex');
158
+ const c = this.#state.connection;
159
+ if (c && same(c.merchant, merchant) && same(c.laneKey, laneKey)) return;
160
+ const recipient = await lookupRecipient(await this.#client(), hexToBytes(merchant));
161
+ if (!recipient) fail('invalid', `${merchant} has not registered for private payments: open its wallet once`);
162
+ for (const t of this.#timers.values()) clearTimeout(t);
163
+ this.#timers.clear();
164
+ this.#fills.clear();
165
+ this.#state.connection = {
166
+ merchant: getAddress(merchant),
167
+ publicKey: bytesToHex(recipient!.publicKey),
168
+ laneKey: laneKey.toLowerCase() as Hex,
169
+ slots: {},
170
+ pools: {},
171
+ };
172
+ await this.#save();
173
+ });
174
+ }
175
+
176
+ /** Readies addresses of `token` ahead of its sales: an app calls it for each token it takes, as it starts and after
177
+ * sales. Nothing happens while enough are ready, or a refill is under way or due. */
178
+ prepare(token: PosToken): void {
179
+ chainOf(this.#network, token.chainId);
180
+ this.#topUp(token);
181
+ }
182
+
183
+ /** How many addresses of `token` are recorded and wait for a sale. */
184
+ ready(token: PosToken): number {
185
+ return this.#state.connection?.pools[pairOf(token)]?.ready.length ?? 0;
186
+ }
187
+
188
+ /** A new sale of `token`, at an address no sale has had: one recorded ahead, or, when none is left, one recorded
189
+ * now. With `via: 'deposit'` it also registers a deposit address in front of it (`registerDeposit`). */
190
+ async next(opts: { via: 'burn' | 'deposit'; token: PosToken; amount?: bigint; refundTo?: Address }): Promise<Sale> {
191
+ const { token } = opts;
192
+ const c = chainOf(this.#network, token.chainId);
193
+ this.#connection();
194
+ const { commitmentsFinal: from } = await new Indexer(c.indexer).status();
195
+ let burnAddress: Address | undefined;
196
+ while (!(burnAddress = this.#connection().pools[pairOf(token)]?.ready.shift())) await this.#fill(token);
197
+ // saved as taken before the deposit relayer is asked: a sale that fails half-way never hands it out again
198
+ const sale: Sale = {
199
+ id: burnAddress,
200
+ chainId: token.chainId,
201
+ tokenId: token.tokenId,
202
+ token: token.address,
203
+ burnAddress,
204
+ depositAddress: null,
205
+ depositFee: null,
206
+ refundTo: null,
207
+ amount: opts.amount ?? null,
208
+ createdMs: Date.now(),
209
+ };
210
+ const watch = { key: tag(token.tokenId, burnAddress), from, scannedTo: from };
211
+ this.#state.sales.push(sale);
212
+ this.#state.seen[burnAddress] = { address: burnAddress, watches: [{ tokenId: token.tokenId, watch }], found: [] };
213
+ await this.#save();
214
+ this.#topUp(token);
215
+ return opts.via === 'deposit' ? this.#exclusive(() => this.#register(sale, opts.refundTo)) : sale;
216
+ }
217
+
218
+ /** Registers a deposit address in front of sale `id`, for the underlying asset; the deposit relayer watches it for 7
219
+ * days. What it cannot wrap (too little, another token) and a rage exit go back to `refundTo`, the merchant's
220
+ * account if absent: name the payer's, so that neither the merchant's account is on chain nor a payment the ASP
221
+ * rejects comes back to the merchant. A sale has one: asked again with the same `refundTo`, it is the same. */
222
+ registerDeposit(id: Address, opts: { refundTo?: Address } = {}): Promise<Sale> {
223
+ return this.#exclusive(() => this.#register(this.#sale(id), opts.refundTo));
224
+ }
225
+
226
+ async #register(sale: Sale, refundTo: Address | undefined): Promise<Sale> {
227
+ const to = refundTo ?? this.#connection().merchant;
228
+ if (!isAddress(to, { strict: false }) || same(to, zeroAddress))
229
+ return fail('invalid', `${to} is not an address to refund to`);
230
+ if (sale.depositAddress) {
231
+ if (same(sale.refundTo!, to)) return sale;
232
+ return fail('invalid', `sale ${sale.id} already has a deposit address, refunding to ${sale.refundTo}`);
233
+ }
234
+ const { depositAddress, fee } = await registerDeposit(this.#chain(sale.chainId), sale.token, sale.burnAddress, to);
235
+ Object.assign(sale, { depositAddress, depositFee: fee, refundTo: to });
236
+ await this.#save();
237
+ return sale;
238
+ }
239
+
240
+ sales(): Sale[] {
241
+ return [...this.#state.sales];
242
+ }
243
+
244
+ /** What reached each of sales `ids`: every transfer to its burn address, with where each stands. One pass per chain
245
+ * for them all, reading only what every wallet downloads, so no server learns which addresses it asks about. A chain
246
+ * that fails keeps what was read before, and its error is returned. */
247
+ refresh(ids: Address[]): Promise<{ sales: SaleStatus[]; errors: unknown[] }> {
248
+ return this.#exclusive(async () => {
249
+ const sales = [...new Map(ids.map((id) => [id.toLowerCase(), this.#sale(id)])).values()];
250
+ const errors: unknown[] = [];
251
+ const chains = [...new Set(sales.map((s) => s.chainId))];
252
+ await Promise.all(
253
+ chains.map((chainId) =>
254
+ this.#refreshChain(
255
+ chainId,
256
+ sales.filter((s) => s.chainId === chainId),
257
+ ).catch((e: unknown) => void errors.push(e)),
258
+ ),
259
+ );
260
+ await this.#save();
261
+ return { sales: sales.map((s) => this.#statusOf(s)), errors };
262
+ });
263
+ }
264
+
265
+ /** What reached sale `id`, and how much is final. */
266
+ async status(id: Address): Promise<SaleStatus> {
267
+ const { sales, errors } = await this.refresh([id]);
268
+ if (errors.length > 0) throw errors[0];
269
+ return sales[0];
270
+ }
271
+
272
+ /** What reached sale `id` as of the last `refresh`, asking no server. */
273
+ lastStatus(id: Address): SaleStatus {
274
+ return this.#statusOf(this.#sale(id));
275
+ }
276
+
277
+ async #refreshChain(chainId: bigint, sales: Sale[]): Promise<void> {
278
+ const c = chainOf(this.#network, chainId);
279
+ const indexer = new Indexer(c.indexer);
280
+ const seen = sales.map((s) => this.#state.seen[s.id]);
281
+ const status = await indexer.status();
282
+ const watches = seen.flatMap((r) => r.watches.map((w) => w.watch));
283
+ const hits = await scan(watches, 4, (p) => indexer.page('tags', p), status.commitmentsFinal, status.commitments);
284
+ applyHits([], seen, hits);
285
+ dropReorged(seen, status.commitmentsFinal);
286
+ await readTransfers(commitmentFields(indexer), seen);
287
+ await readDecisions(new Asp(c.asp), seen);
288
+ // a final payment's transaction is kept; until then it is read again, as a reorg may move what sits at a position
289
+ const txs = this.#state.txs;
290
+ await Promise.all(
291
+ seen
292
+ .flatMap((r) => r.found)
293
+ .map(async (f) => {
294
+ const at = `${chainId}:${f.index}`;
295
+ if (!f.tentative && txs[at]) return;
296
+ const tx = await commitmentTx(this.#chain(chainId), indexer, f.index).catch(() => undefined);
297
+ if (tx === undefined) return;
298
+ if (tx?.hash) txs[at] = tx.hash;
299
+ else delete txs[at];
300
+ }),
301
+ );
302
+ }
303
+
304
+ #statusOf(sale: Sale): SaleStatus {
305
+ const payments = this.#state.seen[sale.id].found
306
+ .filter((f) => f.amount !== null)
307
+ .map((f): Payment => ({
308
+ amount: f.amount!,
309
+ sender: f.sender as Address,
310
+ state: f.tentative ? 'confirming' : f.asp === null ? 'screening' : f.asp,
311
+ txHash: this.#state.txs[`${sale.chainId}:${f.index}`] ?? null,
312
+ }));
313
+ const received = payments.filter((p) => p.state !== 'confirming').reduce((s, p) => s + p.amount, 0n);
314
+ return { sale, payments, received };
315
+ }
316
+
317
+ /** Refills `token`'s addresses once they run low: right away when none is left, otherwise at a random time within
318
+ * `refillWithinMs`. */
319
+ #topUp(token: PosToken): void {
320
+ const pair = pairOf(token);
321
+ const left = this.#state.connection?.pools[pair]?.ready.length ?? 0;
322
+ if (!this.#state.connection || left >= BURN_BLOCK || this.#fills.has(pair) || this.#timers.has(pair)) return;
323
+ // a refill that fails is tried again by the next sale, which tells why
324
+ const fill = () => void this.#fill(token).catch(() => undefined);
325
+ if (left === 0) return fill();
326
+ const timer = setTimeout(() => {
327
+ this.#timers.delete(pair);
328
+ fill();
329
+ }, Math.random() * this.#refillWithinMs);
330
+ (timer as { unref?: () => void }).unref?.(); // a process need not stay up for it
331
+ this.#timers.set(pair, timer);
332
+ }
333
+
334
+ /** Makes `BURN_BLOCK` more addresses of `token`, records them in the lane, then adds them to its pool: one refill at
335
+ * a time per token. One that fails changes nothing. */
336
+ #fill(token: PosToken): Promise<void> {
337
+ const pair = pairOf(token);
338
+ const running = this.#fills.get(pair);
339
+ if (running) return running;
340
+ clearTimeout(this.#timers.get(pair));
341
+ this.#timers.delete(pair);
342
+ const run: Promise<void> = (async () => {
343
+ const c = this.#connection();
344
+ const pool = (c.pools[pair] ??= { next: 0, ready: [] });
345
+ const k = pool.next;
346
+ const laneKey = hexToBytes(c.laneKey);
347
+ const burns = await Promise.all(
348
+ Array.from({ length: BURN_BLOCK }, (_, i) =>
349
+ posBurnAddress(laneKey, token.chainId, token.tokenId, k + i, c.publicKey),
350
+ ),
351
+ );
352
+ const block = {
353
+ chainId: token.chainId,
354
+ tokenId: token.tokenId,
355
+ first: k,
356
+ salts: burns.map((b) => hexToBytes(b.salt)),
357
+ };
358
+ const chain = `${token.chainId}`;
359
+ const slot = await writePosBlock(await this.#client(), laneKey, block, c.slots[chain] ?? 0);
360
+ if (this.#state.connection !== c) return; // connected to another lane meanwhile
361
+ c.slots[chain] = Math.max(c.slots[chain] ?? 0, slot);
362
+ pool.ready.push(...burns.map((b) => b.address));
363
+ pool.next = k + BURN_BLOCK;
364
+ await this.#save();
365
+ })().finally(() => {
366
+ if (this.#fills.get(pair) === run) this.#fills.delete(pair);
367
+ });
368
+ this.#fills.set(pair, run);
369
+ return run;
370
+ }
371
+
372
+ #connection(): Connection {
373
+ return this.#state.connection ?? fail('invalid', "not connected: connect the merchant's wallet first");
374
+ }
375
+
376
+ #sale(id: Address): Sale {
377
+ return this.#state.sales.find((s) => same(s.id, id)) ?? fail('invalid', `no sale ${id}`);
378
+ }
379
+
380
+ #chain(chainId: bigint): Chain {
381
+ let c = this.#chains.get(chainId);
382
+ if (!c) this.#chains.set(chainId, (c = new Chain(chainOf(this.#network, chainId))));
383
+ return c;
384
+ }
385
+
386
+ /** Storage, connected on first use: a point of sale that only follows its sales needs none. */
387
+ #client(): Promise<CanisterClient> {
388
+ const { host, canisterId } = this.#network.storage;
389
+ return (this.#storage ??= CanisterClient.connect(host, canisterId).catch((e: unknown) => {
390
+ this.#storage = undefined;
391
+ throw e;
392
+ }));
393
+ }
394
+
395
+ /** Runs `f` after everything queued before it. */
396
+ #exclusive<T>(f: () => Promise<T>): Promise<T> {
397
+ const run = this.#queue.then(f);
398
+ this.#queue = run.catch(() => undefined);
399
+ return run;
400
+ }
401
+
402
+ /** Saves the state as it is when the save runs, after every save asked for before: an older state never lands last,
403
+ * which would hand out again an address taken since. */
404
+ #save(): Promise<void> {
405
+ const run = this.#saving.then(() => this.#persist.save(encode(this.#state)));
406
+ this.#saving = run.catch(() => undefined);
407
+ return run;
408
+ }
409
+ }
410
+
411
+ function chainOf(network: Network, chainId: bigint): ChainConfig {
412
+ return network.chains.find((c) => c.chainId === chainId) ?? fail('invalid', `the network has no chain ${chainId}`);
413
+ }
414
+
415
+ const tag = (tokenId: number, address: Address) => numberToHex(core.transferTag(tokenId, address), { size: 4 });
package/src/prove.ts ADDED
@@ -0,0 +1,67 @@
1
+ // Groth16 proofs of JoinSplit and RageExit with snarkjs, on the user's device: the witness holds the private keys.
2
+
3
+ import { sha256 } from '@noble/hashes/sha2.js';
4
+ import { bytesToHex } from '@noble/hashes/utils.js';
5
+ import { fail } from './errors.js';
6
+ import type { Circuit } from './network.js';
7
+
8
+ /** A URL's bytes; `file:` URLs from disk, for Node. */
9
+ export async function readUrl(url: string): Promise<Uint8Array> {
10
+ if (url.startsWith('file:')) {
11
+ const fs = 'node:fs/promises';
12
+ const { readFile } = (await import(/* @vite-ignore */ fs)) as { readFile: (path: URL) => Promise<Uint8Array> };
13
+ return readFile(new URL(url));
14
+ }
15
+ const res = await fetch(url);
16
+ if (!res.ok) return fail('unavailable', `${url}: HTTP ${res.status}`);
17
+ return new Uint8Array(await res.arrayBuffer());
18
+ }
19
+
20
+ const artifacts = new Map<string, Promise<Uint8Array>>();
21
+
22
+ /** The artifact at `url`, checked against its pinned SHA-256: a swapped proving key could leak the witness. */
23
+ function artifact(url: string, sha: string): Promise<Uint8Array> {
24
+ let p = artifacts.get(url);
25
+ if (!p) {
26
+ p = readUrl(url).then((bytes) => {
27
+ if (bytesToHex(sha256(bytes)) !== sha.toLowerCase()) fail('invalid', `${url} does not match its SHA-256`);
28
+ return bytes;
29
+ });
30
+ p.catch(() => artifacts.delete(url));
31
+ artifacts.set(url, p);
32
+ }
33
+ return p;
34
+ }
35
+
36
+ /** `Verifier.Groth16Proof`, with pB in the order the Solidity verifier takes. */
37
+ export interface Groth16 {
38
+ pA: [bigint, bigint];
39
+ pB: [[bigint, bigint], [bigint, bigint]];
40
+ pC: [bigint, bigint];
41
+ }
42
+
43
+ export async function prove(circuit: Circuit, input: object): Promise<{ proof: Groth16; signals: bigint[] }> {
44
+ const [wasm, zkey] = await Promise.all([
45
+ artifact(circuit.wasm, circuit.wasmSha256),
46
+ artifact(circuit.zkey, circuit.zkeySha256),
47
+ ]);
48
+ // loaded on the first proof: an app that only reads balances need not carry snarkjs
49
+ const { groth16 } = await import('snarkjs');
50
+ const { proof, publicSignals } = await groth16.fullProve(
51
+ input,
52
+ { type: 'mem', data: wasm },
53
+ { type: 'mem', data: zkey },
54
+ );
55
+ const b = (x: string) => BigInt(x);
56
+ return {
57
+ proof: {
58
+ pA: [b(proof.pi_a[0]), b(proof.pi_a[1])],
59
+ pB: [
60
+ [b(proof.pi_b[0][1]), b(proof.pi_b[0][0])],
61
+ [b(proof.pi_b[1][1]), b(proof.pi_b[1][0])],
62
+ ],
63
+ pC: [b(proof.pi_c[0]), b(proof.pi_c[1])],
64
+ },
65
+ signals: publicSignals.map(b),
66
+ };
67
+ }