@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/core.ts
CHANGED
|
@@ -26,27 +26,17 @@ export interface Burn {
|
|
|
26
26
|
address: Address;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
* responsive; `signal` stops the search. */
|
|
29
|
+
/** The `index`-th burn address of `publicKey` on a chain and token, and its salt, from `burnKey`: the wallet's own burn
|
|
30
|
+
* key, or a point of sale's (`wallet_keys::derive_burn_address`). About 2^16 hashes, spread over the device's cores
|
|
31
|
+
* (Web Workers in a browser, worker threads in Node) so that the app stays responsive; `signal` stops the search. */
|
|
32
32
|
export const burnAddress = (
|
|
33
|
-
|
|
33
|
+
burnKey: Uint8Array,
|
|
34
34
|
chainId: bigint,
|
|
35
35
|
tokenId: number,
|
|
36
36
|
index: number,
|
|
37
|
-
signal?: AbortSignal,
|
|
38
|
-
): Promise<Burn> => search({ seed, chainId, tokenId, index }, signal);
|
|
39
|
-
|
|
40
|
-
/** The `k`-th burn address a point of sale holding `laneKey` makes for its owner's `publicKey` on a chain and token
|
|
41
|
-
* (`keys::pos_burn`), with its salt: about 2^16 hashes too, searched the same way. */
|
|
42
|
-
export const posBurnAddress = (
|
|
43
|
-
laneKey: Uint8Array,
|
|
44
|
-
chainId: bigint,
|
|
45
|
-
tokenId: number,
|
|
46
|
-
k: number,
|
|
47
37
|
publicKey: Hex,
|
|
48
38
|
signal?: AbortSignal,
|
|
49
|
-
): Promise<Burn> => search({
|
|
39
|
+
): Promise<Burn> => search({ burnKey, chainId, tokenId, index, publicKey }, signal);
|
|
50
40
|
|
|
51
41
|
/** A burn address of someone else's `publicKey` (one they published to be paid), with a random salt that has the proof
|
|
52
42
|
* of work: about 2^16 hashes too, searched the same way. Only who holds the salt finds it: the payer, and the owner once
|
|
@@ -55,93 +45,295 @@ export const burnAddressFor = (chainId: bigint, publicKey: Hex, signal?: AbortSi
|
|
|
55
45
|
search({ chainId, publicKey }, signal);
|
|
56
46
|
|
|
57
47
|
export type BurnSearch =
|
|
58
|
-
| {
|
|
59
|
-
| { laneKey: Uint8Array; chainId: bigint; tokenId: number; k: number; publicKey: Hex }
|
|
48
|
+
| { burnKey: Uint8Array; chainId: bigint; tokenId: number; index: number; publicKey: Hex }
|
|
60
49
|
| { chainId: bigint; publicKey: Hex };
|
|
61
50
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
51
|
+
/** Counters per block: about 20 ms of hashing on a laptop, a few times that on a phone. */
|
|
52
|
+
const BLOCK = 512;
|
|
53
|
+
|
|
54
|
+
/** A block of a search: its salt counters `from..=to`, or as many random salts for someone else's key. */
|
|
55
|
+
export interface BurnBlock {
|
|
56
|
+
search: BurnSearch;
|
|
57
|
+
from: number;
|
|
58
|
+
to: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type BurnAnswer = { burn: Burn | null } | { error: string };
|
|
62
|
+
|
|
63
|
+
/** The address a block holds, if any. Its salts count up from a start: one the burn key derives, or for someone else's
|
|
64
|
+
* key a random one below 2^253, so that every salt is a field element. */
|
|
65
|
+
export function runBlock({ search: s, from, to }: BurnBlock): Burn | null {
|
|
66
|
+
let burn: unknown;
|
|
67
|
+
if ('burnKey' in s) burn = core.burnBlock(s.burnKey, s.chainId, s.tokenId, s.index, s.publicKey, from, to);
|
|
68
|
+
else {
|
|
69
|
+
const start = crypto.getRandomValues(new Uint8Array(32));
|
|
70
|
+
start[0] = start[0] & 0x1f;
|
|
71
|
+
burn = core.burnBlockFor(s.chainId, s.publicKey, bytesToHex(start), to - from + 1);
|
|
72
|
+
}
|
|
73
|
+
return (burn as Burn | undefined) ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Outcome = { burn: Burn } | { error: unknown };
|
|
77
|
+
|
|
78
|
+
/** A search in progress, in blocks `0, 1, …` of `BLOCK` counters. From a burn key, the address is the first block's
|
|
79
|
+
* hit, so that every device derives the same one; for someone else's key, it is the first found. */
|
|
80
|
+
export class Search {
|
|
81
|
+
readonly s: BurnSearch;
|
|
82
|
+
readonly #settle: (r: Outcome) => void;
|
|
83
|
+
/** From a burn key: the address is the first block's hit, not the first found. */
|
|
84
|
+
readonly #derived: boolean;
|
|
85
|
+
#settled = false;
|
|
86
|
+
#next = 0;
|
|
87
|
+
/** Blocks a worker took and died with, handed out again before any other. */
|
|
88
|
+
#dropped: number[] = [];
|
|
89
|
+
/** What the finished blocks from `#checked` on hold: every block below it holds none. */
|
|
90
|
+
#found = new Map<number, Burn | null>();
|
|
91
|
+
#checked = 0;
|
|
92
|
+
/** The lowest block known to hold an address: no block above it is needed. */
|
|
93
|
+
#lowest = Infinity;
|
|
94
|
+
|
|
95
|
+
constructor(s: BurnSearch, settle: (r: Outcome) => void) {
|
|
96
|
+
this.s = s;
|
|
97
|
+
this.#settle = settle;
|
|
98
|
+
this.#derived = 'burnKey' in s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
take(): number | undefined {
|
|
102
|
+
if (this.#settled) return undefined;
|
|
103
|
+
if (this.#dropped.length) return this.#dropped.shift();
|
|
104
|
+
return this.#next < this.#lowest ? this.#next++ : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
drop(block: number) {
|
|
108
|
+
this.#dropped.unshift(block);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
finish(block: number, burn: Burn | null) {
|
|
112
|
+
if (this.#settled) return;
|
|
113
|
+
if (!this.#derived) {
|
|
114
|
+
if (burn) this.#end({ burn });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.#found.set(block, burn);
|
|
118
|
+
if (burn) this.#lowest = Math.min(this.#lowest, block);
|
|
119
|
+
for (let found; (found = this.#found.get(this.#checked)) !== undefined; this.#checked++) {
|
|
120
|
+
if (found) return this.#end({ burn: found });
|
|
121
|
+
this.#found.delete(this.#checked);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fail(error: unknown) {
|
|
126
|
+
if (!this.#settled) this.#end({ error });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#end(r: Outcome) {
|
|
130
|
+
this.#settled = true;
|
|
131
|
+
this.#settle(r);
|
|
73
132
|
}
|
|
74
133
|
}
|
|
75
134
|
|
|
76
|
-
|
|
135
|
+
/** A worker running one block at a time. */
|
|
136
|
+
interface Thread {
|
|
137
|
+
job: Job | null;
|
|
138
|
+
/** Whether it ever answered: one that dies before it does never ran. */
|
|
139
|
+
answered: boolean;
|
|
140
|
+
send(b: BurnBlock): void;
|
|
141
|
+
stop(): void;
|
|
142
|
+
/** In Node, a thread keeps the process up while it works, and only then. */
|
|
143
|
+
busy(working: boolean): void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type Job = { search: Search; block: number };
|
|
77
147
|
|
|
78
148
|
/** What this module takes from Node's worker threads, typed here so that the package needs no Node types. */
|
|
79
|
-
type
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
149
|
+
type NodeWorker = new (url: URL) => {
|
|
150
|
+
on(event: 'message', f: (a: BurnAnswer) => void): void;
|
|
151
|
+
on(event: 'error' | 'exit' | 'messageerror', f: () => void): void;
|
|
152
|
+
postMessage(b: BurnBlock): void;
|
|
153
|
+
ref(): void;
|
|
154
|
+
unref(): void;
|
|
85
155
|
terminate(): Promise<number>;
|
|
86
156
|
};
|
|
87
157
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
};
|
|
100
|
-
worker.postMessage(s);
|
|
101
|
-
return () => worker.terminate();
|
|
102
|
-
}
|
|
103
|
-
// variables, so that bundlers for the browser leave Node's module and a second copy of the worker alone
|
|
104
|
-
const [threads, file] = ['node:worker_threads', './burnWorker.js'];
|
|
105
|
-
const { Worker: Thread } = (await import(/* @vite-ignore */ threads).catch(() => ({}))) as { Worker?: NodeThread };
|
|
106
|
-
if (!Thread) return null;
|
|
107
|
-
const thread = new Thread(new URL(file, import.meta.url), { workerData: s });
|
|
108
|
-
thread.once('message', done);
|
|
109
|
-
thread.once('error', () => done(null)); // run from source, as tests are, there is no burnWorker.js
|
|
110
|
-
return () => void thread.terminate();
|
|
111
|
-
}
|
|
158
|
+
/** Searches in progress, oldest first: blocks go to the oldest that has one to hand out. */
|
|
159
|
+
const searches: Search[] = [];
|
|
160
|
+
let threads: Thread[] | null = null;
|
|
161
|
+
let starting = false;
|
|
162
|
+
/** Workers do not run here (a page whose CSP forbids them, a bundler that left burnWorker.js out, tests run from
|
|
163
|
+
* source): blocks run on this thread instead. */
|
|
164
|
+
let here = false;
|
|
165
|
+
let runningHere = false;
|
|
166
|
+
let idle: ReturnType<typeof setTimeout> | undefined;
|
|
167
|
+
/** An idle pool stops after this long, freeing a wasm instance per core. */
|
|
168
|
+
const IDLE_MS = 30_000;
|
|
112
169
|
|
|
113
170
|
async function search(s: BurnSearch, signal?: AbortSignal): Promise<Burn> {
|
|
114
|
-
signal?.throwIfAborted();
|
|
115
171
|
await initCore();
|
|
172
|
+
signal?.throwIfAborted();
|
|
116
173
|
return new Promise((resolve, reject) => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const settle = (f: () => void) => {
|
|
120
|
-
if (settled) return;
|
|
121
|
-
settled = true;
|
|
122
|
-
stop?.();
|
|
174
|
+
const abort = () => one.fail(signal!.reason);
|
|
175
|
+
const one = new Search(s, (r) => {
|
|
123
176
|
signal?.removeEventListener('abort', abort);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
resolve(searchHere(s));
|
|
130
|
-
} catch (err) {
|
|
131
|
-
reject(err);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
const abort = () => settle(() => reject(signal!.reason));
|
|
177
|
+
searches.splice(searches.indexOf(one), 1);
|
|
178
|
+
if ('burn' in r) resolve(r.burn);
|
|
179
|
+
else reject(r.error);
|
|
180
|
+
});
|
|
135
181
|
signal?.addEventListener('abort', abort, { once: true });
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
182
|
+
searches.push(one);
|
|
183
|
+
dispatch();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function nextJob(): Job | null {
|
|
188
|
+
for (const search of searches) {
|
|
189
|
+
const block = search.take();
|
|
190
|
+
if (block !== undefined) return { search, block };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const blockOf = ({ search, block }: Job): BurnBlock => ({
|
|
196
|
+
search: search.s,
|
|
197
|
+
from: block * BLOCK,
|
|
198
|
+
to: block * BLOCK + BLOCK - 1,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
function deliver({ search, block }: Job, a: BurnAnswer) {
|
|
202
|
+
if ('error' in a) search.fail(new Error(a.error));
|
|
203
|
+
else search.finish(block, a.burn);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Hands blocks to idle threads, starting the pool if it is not running. */
|
|
207
|
+
function dispatch() {
|
|
208
|
+
clearTimeout(idle);
|
|
209
|
+
if (here) return void runHere();
|
|
210
|
+
if (!threads) return void start();
|
|
211
|
+
for (const t of threads) {
|
|
212
|
+
if (t.job) continue;
|
|
213
|
+
const job = nextJob();
|
|
214
|
+
if (!job) break;
|
|
215
|
+
t.job = job;
|
|
216
|
+
t.busy(true);
|
|
217
|
+
t.send(blockOf(job));
|
|
218
|
+
}
|
|
219
|
+
if (!searches.length && threads.every((t) => !t.job)) {
|
|
220
|
+
idle = setTimeout(stop, IDLE_MS);
|
|
221
|
+
(idle as { unref?: () => void }).unref?.(); // a process need not stay up for it
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function start() {
|
|
226
|
+
if (starting) return;
|
|
227
|
+
starting = true;
|
|
228
|
+
const cores = globalThis.navigator?.hardwareConcurrency ?? 1;
|
|
229
|
+
// Node leaves a core to its event loop: a point of sale's server answers while it searches
|
|
230
|
+
const count = Math.max(1, Math.min(16, typeof Worker !== 'undefined' ? cores : cores - 1));
|
|
231
|
+
threads = await spawn(count).catch(() => []);
|
|
232
|
+
starting = false;
|
|
233
|
+
if (!threads.length) {
|
|
234
|
+
threads = null;
|
|
235
|
+
void runHere();
|
|
236
|
+
}
|
|
237
|
+
dispatch();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function stop() {
|
|
241
|
+
const stopped = threads ?? [];
|
|
242
|
+
threads = null;
|
|
243
|
+
for (const t of stopped) t.stop();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function answered(t: Thread, a: BurnAnswer) {
|
|
247
|
+
t.answered = true;
|
|
248
|
+
const job = t.job!;
|
|
249
|
+
t.job = null;
|
|
250
|
+
t.busy(false);
|
|
251
|
+
deliver(job, a);
|
|
252
|
+
dispatch();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function died(t: Thread) {
|
|
256
|
+
if (!threads?.includes(t)) return; // stopped, or already counted
|
|
257
|
+
threads = threads.filter((other) => other !== t);
|
|
258
|
+
if (t.job) t.job.search.drop(t.job.block);
|
|
259
|
+
if (!t.answered) {
|
|
260
|
+
// it never ran: neither will the others
|
|
261
|
+
for (const other of threads) if (other.job) other.job.search.drop(other.job.block);
|
|
262
|
+
stop();
|
|
263
|
+
void runHere();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!threads.length) threads = null; // started again on the next dispatch
|
|
267
|
+
dispatch();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Starts `count` workers; none where there are no workers. */
|
|
271
|
+
async function spawn(count: number): Promise<Thread[]> {
|
|
272
|
+
if (typeof Worker !== 'undefined') {
|
|
273
|
+
return Array.from({ length: count }, () => {
|
|
274
|
+
// this exact form is what bundlers (Vite, webpack) look for to emit the worker
|
|
275
|
+
const worker = new Worker(new URL('./burnWorker.js', import.meta.url), { type: 'module' });
|
|
276
|
+
const t: Thread = {
|
|
277
|
+
job: null,
|
|
278
|
+
answered: false,
|
|
279
|
+
send: (b) => worker.postMessage(b),
|
|
280
|
+
stop: () => worker.terminate(),
|
|
281
|
+
busy: () => {},
|
|
282
|
+
};
|
|
283
|
+
worker.onmessage = ({ data }: MessageEvent<BurnAnswer>) => answered(t, data);
|
|
284
|
+
worker.onerror = (e) => {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
died(t);
|
|
287
|
+
};
|
|
288
|
+
worker.onmessageerror = () => died(t);
|
|
289
|
+
return t;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// variables, so that bundlers for the browser leave Node's module and a second copy of the worker alone
|
|
293
|
+
const [threadsModule, file] = ['node:worker_threads', './burnWorker.js'];
|
|
294
|
+
const { Worker: ThreadWorker } = (await import(/* @vite-ignore */ threadsModule).catch(() => ({}))) as {
|
|
295
|
+
Worker?: NodeWorker;
|
|
296
|
+
};
|
|
297
|
+
if (!ThreadWorker) return [];
|
|
298
|
+
return Array.from({ length: count }, () => {
|
|
299
|
+
const worker = new ThreadWorker(new URL(file, import.meta.url));
|
|
300
|
+
worker.unref();
|
|
301
|
+
const t: Thread = {
|
|
302
|
+
job: null,
|
|
303
|
+
answered: false,
|
|
304
|
+
send: (b) => worker.postMessage(b),
|
|
305
|
+
stop: () => void worker.terminate(),
|
|
306
|
+
busy: (working) => (working ? worker.ref() : worker.unref()),
|
|
307
|
+
};
|
|
308
|
+
worker.on('message', (a) => answered(t, a));
|
|
309
|
+
// run from source, as tests are, there is no burnWorker.js: the thread exits before it answers
|
|
310
|
+
for (const event of ['error', 'exit', 'messageerror'] as const) worker.on(event, () => died(t));
|
|
311
|
+
return t;
|
|
146
312
|
});
|
|
147
313
|
}
|
|
314
|
+
|
|
315
|
+
/** Runs the blocks on this thread, yielding to the event loop every ~100 ms, and no more often: each yield is a timer,
|
|
316
|
+
* which costs 4 ms in a visible tab and a whole second in a hidden one. */
|
|
317
|
+
async function runHere() {
|
|
318
|
+
if (!here) {
|
|
319
|
+
here = true;
|
|
320
|
+
console.warn('zerc20: burn addresses are searched on the main thread, slower: no worker starts here');
|
|
321
|
+
}
|
|
322
|
+
if (runningHere) return;
|
|
323
|
+
runningHere = true;
|
|
324
|
+
let since = Date.now();
|
|
325
|
+
for (let job; (job = nextJob());) {
|
|
326
|
+
let a: BurnAnswer;
|
|
327
|
+
try {
|
|
328
|
+
a = { burn: runBlock(blockOf(job)) };
|
|
329
|
+
} catch (e) {
|
|
330
|
+
a = { error: e instanceof Error ? e.message : String(e) };
|
|
331
|
+
}
|
|
332
|
+
deliver(job, a);
|
|
333
|
+
if (Date.now() - since >= 100) {
|
|
334
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
335
|
+
since = Date.now();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
runningHere = false;
|
|
339
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type ErrorCode =
|
|
|
23
23
|
| 'user-rejected'
|
|
24
24
|
/** The wallet is open in another tab or window: close it there (`close()`), then open it here. */
|
|
25
25
|
| 'wallet-in-use'
|
|
26
|
+
/** This wallet watches its account by its view key (`Wallet.watch`): it spends and writes nothing. */
|
|
27
|
+
| 'read-only'
|
|
26
28
|
/** This wallet object was closed (`close()`): open the wallet again to go on. */
|
|
27
29
|
| 'wallet-closed'
|
|
28
30
|
/** A server answered something that does not match the chain. */
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// await (await wallet.deposit({ token, amount })).wait();
|
|
7
7
|
// const q = await wallet.fees({ kind: 'send', token, amount });
|
|
8
8
|
// await (await wallet.send({ to: '0x…', token, amount, maxFee: q.fee })).wait();
|
|
9
|
+
// const watching = await Wallet.watch({ network, viewKey: wallet.viewKey(), persist }); // elsewhere, spends nothing
|
|
9
10
|
//
|
|
10
11
|
// Nothing secret leaves the device unencrypted: JoinSplit proofs are made here, a merge's by the merge prover, and the
|
|
11
12
|
// attester and the prover get the witness sealed to an enclave whose measurement the wallet checks. Servers are only
|
|
@@ -23,7 +24,8 @@ export {
|
|
|
23
24
|
networkFromJson,
|
|
24
25
|
} from './network.js';
|
|
25
26
|
export { type Payment, PointOfSale, type PosToken, type Sale, type SaleStatus } from './pos.js';
|
|
26
|
-
export { seedFromSignature } from './seed.js';
|
|
27
|
+
export { accountKey, seedFromSignature } from './seed.js';
|
|
28
|
+
export { viewKeyAccount } from './viewKey.js';
|
|
27
29
|
export {
|
|
28
30
|
type Activity,
|
|
29
31
|
type Balance,
|
|
@@ -38,4 +40,5 @@ export {
|
|
|
38
40
|
type OperationStatus,
|
|
39
41
|
type PendingReason,
|
|
40
42
|
Wallet,
|
|
43
|
+
type WatchOptions,
|
|
41
44
|
} from './wallet.js';
|
package/src/pos.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
zeroAddress,
|
|
17
17
|
} from 'viem';
|
|
18
18
|
import { Chain, type Token } from './chain.js';
|
|
19
|
-
import { core, initCore
|
|
19
|
+
import { burnAddress, core, initCore } from './core.js';
|
|
20
20
|
import { fail } from './errors.js';
|
|
21
21
|
import { type ChainConfig, type Network, checkNetwork } from './network.js';
|
|
22
22
|
import { scan } from './scan.js';
|
|
@@ -346,7 +346,7 @@ export class PointOfSale {
|
|
|
346
346
|
const laneKey = hexToBytes(c.laneKey);
|
|
347
347
|
const burns = await Promise.all(
|
|
348
348
|
Array.from({ length: BURN_BLOCK }, (_, i) =>
|
|
349
|
-
|
|
349
|
+
burnAddress(laneKey, token.chainId, token.tokenId, k + i, c.publicKey),
|
|
350
350
|
),
|
|
351
351
|
);
|
|
352
352
|
const block = {
|
package/src/rageExit.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { type Address, type Hex, decodeEventLog, numberToHex, parseAbi } from 'viem';
|
|
6
6
|
import { depositAddressAbi, ledgerAbi, verifierAbi } from './chain.js';
|
|
7
|
-
import { type ChainCtx, type Ctx, chainCtx, spendKey, walletSees } from './context.js';
|
|
7
|
+
import { type ChainCtx, type Ctx, chainCtx, spendKey, walletOn, walletSees } from './context.js';
|
|
8
8
|
import { core } from './core.js';
|
|
9
9
|
import { fail, guard } from './errors.js';
|
|
10
10
|
import { prove } from './prove.js';
|
|
@@ -85,9 +85,8 @@ export async function rageExit(ctx: Ctx, record: DepositRecord, index: number):
|
|
|
85
85
|
const c = chainCtx(ctx, record.chainId);
|
|
86
86
|
const found = record.found.find((f) => f.index === index) ?? fail('invalid', `no deposit at ${index}`);
|
|
87
87
|
if (found.spent) fail('invalid', 'the deposit is already spent');
|
|
88
|
-
const wc = ctx.
|
|
89
|
-
const account =
|
|
90
|
-
if ((await wc.getChainId()) !== Number(record.chainId)) await guard(wc.switchChain({ id: Number(record.chainId) }));
|
|
88
|
+
const wc = await walletOn(ctx, record.chainId);
|
|
89
|
+
const account = ctx.account;
|
|
91
90
|
const hashes: Hex[] = [];
|
|
92
91
|
const send: Sender = async (address, abi, functionName, args) => {
|
|
93
92
|
const hash = await guard(
|
package/src/seed.ts
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
// A seed from the wallet's own signature, so the account alone restores it. It holds only for wallets whose
|
|
2
2
|
// signatures are deterministic (RFC 6979, as MetaMask's and hardware wallets' are): keep the fingerprint, and if a
|
|
3
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.
|
|
4
|
+
// Anything that gets the user to sign the same message gets the seed. Or the other way round, an account from a seed:
|
|
5
|
+
// for an app whose seed comes from elsewhere (a passkey), and that has no wallet of its own.
|
|
5
6
|
|
|
6
7
|
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
8
|
+
import { entropyToMnemonic } from '@scure/bip39';
|
|
9
|
+
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
7
10
|
import { type Hex, type WalletClient, bytesToHex, hexToBytes, keccak256 } from 'viem';
|
|
11
|
+
import { mnemonicToAccount } from 'viem/accounts';
|
|
8
12
|
import { fail, guard } from './errors.js';
|
|
9
13
|
|
|
14
|
+
/** The private key of the account a seed's own app holds its public funds in: the first account of the seed's recovery
|
|
15
|
+
* phrase (the seed as 24 BIP-39 words) by the standard derivation, BIP-44's m/44'/60'/0'/0/0. Any wallet given the
|
|
16
|
+
* phrase opens the same account, so its public funds stay reachable without this app. */
|
|
17
|
+
export function accountKey(seed: Uint8Array): Hex {
|
|
18
|
+
const key = mnemonicToAccount(entropyToMnemonic(seed, wordlist)).getHdKey().privateKey;
|
|
19
|
+
return bytesToHex(key ?? fail('invalid', 'the seed derives no account key'));
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
const message = (address: Hex) =>
|
|
11
23
|
[
|
|
12
24
|
'zERC20 wallet seed',
|
package/src/spend.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
parseAbiParameters,
|
|
22
22
|
} from 'viem';
|
|
23
23
|
import { UNWRAP_INTENT } from './chain.js';
|
|
24
|
-
import { type Ctx, chainCtx, spendKey } from './context.js';
|
|
24
|
+
import { type Ctx, chainCtx, owner, spendKey } from './context.js';
|
|
25
25
|
import { core } from './core.js';
|
|
26
26
|
import { fail } from './errors.js';
|
|
27
27
|
import { prove } from './prove.js';
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
relayerFees,
|
|
37
37
|
requestProof,
|
|
38
38
|
} from './services.js';
|
|
39
|
-
import type
|
|
39
|
+
import { type NoteRecord, type OperationRecord, type Position, type SpendOperation, unspent } from './state.js';
|
|
40
40
|
import { commitmentsAt, noteReceipt, storageState, takeRefresh, tokenIds } from './sync.js';
|
|
41
41
|
import { aspPath, aspPaths, commitmentPath, commitmentPaths } from './tree.js';
|
|
42
42
|
|
|
@@ -141,7 +141,7 @@ export function coins(ctx: Ctx, chainId: bigint, tokenId: number, rootSize: numb
|
|
|
141
141
|
);
|
|
142
142
|
// final or not: once a proved root the chain accepts covers it, a reorg that moves it takes that root back too, and
|
|
143
143
|
// a JoinSplit against it then fails on chain, which frees its inputs
|
|
144
|
-
const usable = (f:
|
|
144
|
+
const usable = (f: Position) => unspent(f) && f.index < rootSize && !locked.has(f.index);
|
|
145
145
|
const notes = ctx.state.notes
|
|
146
146
|
.filter((n) => n.chainId === chainId && n.tokenId === tokenId)
|
|
147
147
|
.flatMap((n) =>
|
|
@@ -168,7 +168,7 @@ export function coins(ctx: Ctx, chainId: bigint, tokenId: number, rootSize: numb
|
|
|
168
168
|
/** Whether any of `tokenId`'s notes or deposits is on chain but not spendable yet: not final or confirmed, not
|
|
169
169
|
* screened, or not under the proved root of size `rootSize`. A note only announced does not count: it may never land. */
|
|
170
170
|
export function settling(ctx: Ctx, chainId: bigint, tokenId: number, rootSize: number): boolean {
|
|
171
|
-
const waiting = (f:
|
|
171
|
+
const waiting = (f: Position) => unspent(f) && f.index >= rootSize;
|
|
172
172
|
// a zero-value change is never spendable: it is there to carry its payment's receipt
|
|
173
173
|
const notes = ctx.state.notes.filter((n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n);
|
|
174
174
|
const deposits = ctx.state.deposits.filter((d) => d.chainId === chainId);
|
|
@@ -305,7 +305,7 @@ export async function planStep(ctx: Ctx, op: SpendOperation): Promise<Step | Mer
|
|
|
305
305
|
const all = coins(ctx, op.chainId, op.tokenId, root?.size ?? 0);
|
|
306
306
|
const { relayer, publicKey: relayerKey, fees } = await relayerFees(c.config);
|
|
307
307
|
const fee = fees.get(op.tokenId)?.fee ?? fail('unavailable', `the relayer does not relay token ${op.tokenId}`);
|
|
308
|
-
const self =
|
|
308
|
+
const self = owner(ctx)!.publicKey;
|
|
309
309
|
const out = (publicKey: Hex, value: bigint, role: Output['role'], salt: Hex = fresh()): Output => ({
|
|
310
310
|
publicKey,
|
|
311
311
|
value,
|
package/src/state.ts
CHANGED
|
@@ -21,10 +21,16 @@ export interface Position {
|
|
|
21
21
|
spent: boolean;
|
|
22
22
|
/** Looking for its nullifier, once final. */
|
|
23
23
|
nullifier?: Watch;
|
|
24
|
+
/** Where among the nullifiers its own was seen past the final count: spent by a JoinSplit in a block, which a reorg
|
|
25
|
+
* may still drop. */
|
|
26
|
+
nullifiedAt?: number;
|
|
24
27
|
/** Its block's time (ms), read with its commitment: the same on every device. Unset until read. */
|
|
25
28
|
atMs?: number;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/** Neither spent nor spent by a JoinSplit in a block: one waits only to be final, so it is gone already. */
|
|
32
|
+
export const unspent = (f: Position): boolean => !f.spent && f.nullifiedAt === undefined;
|
|
33
|
+
|
|
28
34
|
/** What a payment, withdrawal or merge was, kept on its change or merged note in storage (the storage SDK's
|
|
29
35
|
* `Receipt`): every device of the wallet shows it once that note lands. */
|
|
30
36
|
export interface NoteReceipt {
|
|
@@ -51,6 +57,9 @@ export interface NoteRecord {
|
|
|
51
57
|
atNs: bigint | null;
|
|
52
58
|
/** For a note paid to a receive code, the code's index: the code's own key owns it. Otherwise the wallet's key does. */
|
|
53
59
|
keyIndex?: number;
|
|
60
|
+
/** Paid to a receive code whose key was not derived when storage told of it (another device's code, while the seed
|
|
61
|
+
* was not held): its hash and watch wait for the seed, and nothing counts it until then. */
|
|
62
|
+
unkeyed?: true;
|
|
54
63
|
/** Its hash under the key that owns it. */
|
|
55
64
|
noteHash: Hex;
|
|
56
65
|
/** Its tag, watched until it is found where no reorg moves it. */
|
|
@@ -183,22 +192,23 @@ export const emptyWalletState = (address: Hex): WalletState => ({
|
|
|
183
192
|
});
|
|
184
193
|
|
|
185
194
|
const TAG = new TextEncoder().encode('zerc20/wallet/state/v1');
|
|
186
|
-
|
|
195
|
+
/** The key the state is sealed under: the seed's, kept while the seed is not, so that a sync saves without it. */
|
|
196
|
+
export const stateKey = (seed: Uint8Array) => hkdf(sha256, seed, TAG, new TextEncoder().encode('wallet-state'), 32);
|
|
187
197
|
|
|
188
|
-
/** `state` as bytes only the seed opens: `nonce(12) ‖ ChaCha20-Poly1305(JSON)`. */
|
|
189
|
-
export function sealState(
|
|
198
|
+
/** `state` as bytes only the seed's `stateKey` opens: `nonce(12) ‖ ChaCha20-Poly1305(JSON)`. */
|
|
199
|
+
export function sealState(key: Uint8Array, state: WalletState): Uint8Array {
|
|
190
200
|
const json = JSON.stringify(state, (_, v: unknown) =>
|
|
191
201
|
typeof v === 'bigint' ? { $n: v.toString() } : v instanceof Uint8Array ? { $b: bytesToHex(v) } : v,
|
|
192
202
|
);
|
|
193
203
|
const nonce = randomBytes(12);
|
|
194
|
-
const sealed = chacha20poly1305(
|
|
204
|
+
const sealed = chacha20poly1305(key, nonce, TAG).encrypt(new TextEncoder().encode(json));
|
|
195
205
|
return new Uint8Array([...nonce, ...sealed]);
|
|
196
206
|
}
|
|
197
207
|
|
|
198
|
-
/** The state `sealState` wrote, or `null` if `bytes` is not one
|
|
199
|
-
export function openState(
|
|
208
|
+
/** The state `sealState` wrote, or `null` if `bytes` is not one `key` opens. */
|
|
209
|
+
export function openState(key: Uint8Array, bytes: Uint8Array): WalletState | null {
|
|
200
210
|
try {
|
|
201
|
-
const plain = chacha20poly1305(
|
|
211
|
+
const plain = chacha20poly1305(key, bytes.subarray(0, 12), TAG).decrypt(bytes.subarray(12));
|
|
202
212
|
const state = JSON.parse(new TextDecoder().decode(plain), (_, v: unknown) => {
|
|
203
213
|
if (typeof v === 'object' && v !== null && '$n' in v) return BigInt(v.$n as string);
|
|
204
214
|
if (typeof v === 'object' && v !== null && '$b' in v) return hexToBytes(v.$b as string);
|