@zerc20/wallet-sdk 0.1.0 → 0.3.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/chain.d.ts +19 -5
- package/dist/chain.js +17 -11
- package/dist/chain.js.map +1 -1
- package/dist/context.d.ts +23 -4
- package/dist/context.js +35 -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 +2 -2
- package/dist/index.js +2 -2
- 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/services.d.ts +5 -5
- package/dist/services.js +10 -7
- package/dist/services.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 +90 -25
- package/dist/sync.js.map +1 -1
- package/dist/wallet.d.ts +27 -13
- package/dist/wallet.js +125 -62
- package/dist/wallet.js.map +1 -1
- package/package.json +4 -3
- package/src/burnWorker.ts +9 -10
- package/src/chain.ts +35 -12
- package/src/context.ts +48 -8
- package/src/core.ts +279 -87
- package/src/errors.ts +2 -0
- package/src/index.ts +2 -2
- package/src/pos.ts +2 -2
- package/src/rageExit.ts +3 -4
- package/src/seed.ts +13 -1
- package/src/services.ts +18 -7
- package/src/spend.ts +5 -5
- package/src/state.ts +17 -7
- package/src/sync.ts +95 -24
- package/src/wallet.ts +159 -64
- package/wasm/zerc20_wallet_core.d.ts +16 -13
- package/wasm/zerc20_wallet_core.js +49 -42
- package/wasm/zerc20_wallet_core_bg.wasm +0 -0
- package/wasm/zerc20_wallet_core_bg.wasm.d.ts +3 -3
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// attester and the prover get the witness sealed to an enclave whose measurement the wallet checks. Servers are only
|
|
12
12
|
// ever asked for what every wallet downloads.
|
|
13
13
|
|
|
14
|
-
export { Chain, type Token, NATIVE, UNWRAP_INTENT } from './chain.js';
|
|
14
|
+
export { Chain, type Referrer, type Token, MAX_REFERRAL_BPS, NATIVE, UNWRAP_INTENT } from './chain.js';
|
|
15
15
|
export { type ErrorCode, WalletError } from './errors.js';
|
|
16
16
|
export {
|
|
17
17
|
type AttesterTrust,
|
|
@@ -23,7 +23,7 @@ export {
|
|
|
23
23
|
networkFromJson,
|
|
24
24
|
} from './network.js';
|
|
25
25
|
export { type Payment, PointOfSale, type PosToken, type Sale, type SaleStatus } from './pos.js';
|
|
26
|
-
export { seedFromSignature } from './seed.js';
|
|
26
|
+
export { accountKey, seedFromSignature } from './seed.js';
|
|
27
27
|
export {
|
|
28
28
|
type Activity,
|
|
29
29
|
type Balance,
|
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/services.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// see the public statement and a witness sealed to their enclave, the transact-relayer a finished request, the
|
|
3
3
|
// deposit-relayer a burn address.
|
|
4
4
|
|
|
5
|
-
import { type Address, type Hex, bytesToHex, hexToBytes } from 'viem';
|
|
6
|
-
import type { Chain } from './chain.js';
|
|
5
|
+
import { type Address, type Hex, bytesToHex, hexToBytes, zeroAddress } from 'viem';
|
|
6
|
+
import type { Chain, Referrer } from './chain.js';
|
|
7
7
|
import { core } from './core.js';
|
|
8
8
|
import { WalletError, fail } from './errors.js';
|
|
9
9
|
import { type AttesterTrust, type ChainConfig, LOCAL_CHAIN_ID, type Network } from './network.js';
|
|
@@ -75,6 +75,8 @@ export async function attest(c: ChainConfig, statement: object, witness: string,
|
|
|
75
75
|
if (typeof r.body !== 'string') return r.body.attestation;
|
|
76
76
|
// the enclave restarted with a new key between the fetch and the request
|
|
77
77
|
if (r.body.includes('fetch /attestation again') && attempt === 0) continue;
|
|
78
|
+
// a screening's verdict: asking again gets the same, and spends the notes' screenings for nothing
|
|
79
|
+
if (r.status === 403) return fail('recipient-refused', `attester: ${r.body}`);
|
|
78
80
|
// a root its indexer has not derived yet is a 400 too, but one that passes
|
|
79
81
|
const later = r.status !== 400 || r.body.includes('retry once');
|
|
80
82
|
return fail(later ? 'unavailable' : 'invalid', `attester: ${r.body}`);
|
|
@@ -161,23 +163,32 @@ export async function proofOf(
|
|
|
161
163
|
return r.body;
|
|
162
164
|
}
|
|
163
165
|
|
|
164
|
-
/** A deposit address that flushes whatever reaches it into `burnAddress`, less `fee` paid to `feeRecipient`
|
|
165
|
-
* refunds go to `refundAddress`. What the relayer says is checked against the
|
|
166
|
-
* the funds, and the fee it names is the one the address takes. */
|
|
166
|
+
/** A deposit address that flushes whatever reaches it into `burnAddress`, less `fee` paid to `feeRecipient` and the
|
|
167
|
+
* referrer's share of the rest each time; refunds go to `refundAddress`. What the relayer says is checked against the
|
|
168
|
+
* factory: an address of its own would take the funds, and the fee it names is the one the address takes. */
|
|
167
169
|
export async function registerDeposit(
|
|
168
170
|
chain: Chain,
|
|
169
171
|
token: Address,
|
|
170
172
|
burnAddress: Hex,
|
|
171
173
|
refundAddress: Address,
|
|
174
|
+
referrer: Referrer = { address: zeroAddress, bps: 0 },
|
|
172
175
|
): Promise<{ depositAddress: Address; feeRecipient: Address; fee: bigint }> {
|
|
173
176
|
const r = await post<{ depositAddress: Address; feeRecipient: Address; fee: Hex }>(
|
|
174
177
|
`${chain.config.depositRelayer}/register`,
|
|
175
|
-
{ token, burnAddress, refundAddress },
|
|
178
|
+
{ token, burnAddress, refundAddress, referrer: referrer.address, referralBps: referrer.bps },
|
|
176
179
|
);
|
|
177
180
|
if (typeof r.body === 'string') return fail('unavailable', `deposit-relayer: ${r.body}`);
|
|
178
181
|
const { depositAddress, feeRecipient } = r.body;
|
|
179
182
|
const fee = BigInt(r.body.fee);
|
|
180
|
-
const expected = await chain.predictDepositAddress(
|
|
183
|
+
const expected = await chain.predictDepositAddress(
|
|
184
|
+
token,
|
|
185
|
+
burnAddress,
|
|
186
|
+
refundAddress,
|
|
187
|
+
feeRecipient,
|
|
188
|
+
fee,
|
|
189
|
+
referrer.address,
|
|
190
|
+
referrer.bps,
|
|
191
|
+
);
|
|
181
192
|
if (depositAddress.toLowerCase() !== expected.toLowerCase())
|
|
182
193
|
fail('indexer-mismatch', `the deposit relayer gave ${depositAddress}, not the factory's ${expected}`);
|
|
183
194
|
return { depositAddress, feeRecipient, fee };
|
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);
|
package/src/sync.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type OwnReceipt,
|
|
10
10
|
type Receipt,
|
|
11
11
|
type SyncState,
|
|
12
|
+
burnKey,
|
|
12
13
|
commit,
|
|
13
14
|
emptyState,
|
|
14
15
|
planBurnBlock,
|
|
@@ -16,12 +17,12 @@ import {
|
|
|
16
17
|
refresh,
|
|
17
18
|
} from '@zerc20/storage-sdk';
|
|
18
19
|
import { type Hex, bytesToHex, hexToBytes, numberToHex, parseAbiItem } from 'viem';
|
|
19
|
-
import type { Chain } from './chain.js';
|
|
20
|
-
import { type ChainCtx, type Ctx, chainCtx, chainState,
|
|
20
|
+
import type { Chain, Token } from './chain.js';
|
|
21
|
+
import { type ChainCtx, type Ctx, chainCtx, chainState, owner } from './context.js';
|
|
21
22
|
import { burnAddress, core } from './core.js';
|
|
22
23
|
import { type Hit, type Watch, scan } from './scan.js';
|
|
23
24
|
import { type Asp, type AspStatus, type Indexer, PAGE } from './servers.js';
|
|
24
|
-
import type { DepositRecord, NoteReceipt, NoteRecord, Sample } from './state.js';
|
|
25
|
+
import type { DepositRecord, NoteReceipt, NoteRecord, Position, Sample } from './state.js';
|
|
25
26
|
|
|
26
27
|
/** The ledger's log of each commitment, found by its index (`contracts/src/CommitmentLedger.sol`). */
|
|
27
28
|
const committedEvent = parseAbiItem('event Committed(uint64 indexed index, uint256[4] fields)');
|
|
@@ -47,7 +48,7 @@ export function commitmentsAt(samples: Sample[], atMs: number): number {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** A note's hash under the key that owns it (the wallet's, or with `keyIndex` a receive code's), and the watch for its
|
|
50
|
-
* tag. */
|
|
51
|
+
* tag. Null while that key's owner is not derived. */
|
|
51
52
|
function watchFor(
|
|
52
53
|
ctx: Ctx,
|
|
53
54
|
chainId: bigint,
|
|
@@ -57,8 +58,9 @@ function watchFor(
|
|
|
57
58
|
keyIndex: number | undefined,
|
|
58
59
|
from: number,
|
|
59
60
|
) {
|
|
60
|
-
const
|
|
61
|
-
|
|
61
|
+
const key = owner(ctx, keyIndex === undefined ? undefined : { chainId, tokenId, index: keyIndex });
|
|
62
|
+
if (!key) return null;
|
|
63
|
+
const noteHash = core.noteHash(chainId, key.publicKey, `0x${value.toString(16)}`, tokenId, salt) as Hex;
|
|
62
64
|
return { noteHash, watch: { key: numberToHex(core.noteTag(tokenId, noteHash), { size: 4 }), from, scannedTo: from } };
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -214,7 +216,7 @@ export function takeDeposit(ctx: Ctx, deposit: Deposit, payer: Uint8Array, token
|
|
|
214
216
|
if (!tokens.get(chainId)?.includes(tokenId)) return;
|
|
215
217
|
const salt = hex32(deposit.burnSalt);
|
|
216
218
|
if (ctx.state.deposits.some((d) => d.chainId === chainId && d.salt === salt)) return;
|
|
217
|
-
const address = core.burnAddressOf(chainId,
|
|
219
|
+
const address = core.burnAddressOf(chainId, owner(ctx)!.publicKey, salt) as Hex | undefined;
|
|
218
220
|
if (!address) return;
|
|
219
221
|
const atMs = Number(deposit.atNs / 1_000_000n);
|
|
220
222
|
// storage held it before the payer's transfer; a day of margin covers clock skew
|
|
@@ -246,7 +248,7 @@ function takeBurnBlock(
|
|
|
246
248
|
tokens: Map<bigint, number[]>,
|
|
247
249
|
): void {
|
|
248
250
|
if (!tokens.has(b.chainId)) return;
|
|
249
|
-
const { publicKey } =
|
|
251
|
+
const { publicKey } = owner(ctx)!;
|
|
250
252
|
const atMs = Number(b.atNs / 1_000_000n);
|
|
251
253
|
const from = commitmentsAt(chainState(ctx, b.chainId).samples, atMs - DAY_MS);
|
|
252
254
|
b.salts.forEach((bytes, i) => {
|
|
@@ -285,8 +287,22 @@ export function unusedBurn(ctx: Ctx, chainId: bigint, tokenId: number): DepositR
|
|
|
285
287
|
return best;
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
/** The index of the next burn address of a token to record: past every one recorded, here or by another device. */
|
|
291
|
+
function toRecord(ctx: Ctx, chainId: bigint, tokenId: number): number {
|
|
292
|
+
let index = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
|
|
293
|
+
for (const d of ctx.state.deposits) {
|
|
294
|
+
if (d.chainId === chainId && d.tokenId === tokenId && !d.managed) index = Math.max(index, d.burnIndex + 1);
|
|
295
|
+
}
|
|
296
|
+
return index;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The index of the burn address of a token that is handed out next, and so of the receive code: the one recorded
|
|
300
|
+
* ahead, or else the next to record. */
|
|
301
|
+
export const nextBurn = (ctx: Ctx, chainId: bigint, tokenId: number): number =>
|
|
302
|
+
unusedBurn(ctx, chainId, tokenId)?.burnIndex ?? toRecord(ctx, chainId, tokenId);
|
|
303
|
+
|
|
288
304
|
/** Records this wallet's next burn address of a token in storage (a burn block of one), then watches it. Every device
|
|
289
|
-
* of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes. */
|
|
305
|
+
* of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes; needs no seed. */
|
|
290
306
|
export async function recordBurn(
|
|
291
307
|
ctx: Ctx,
|
|
292
308
|
chainId: bigint,
|
|
@@ -294,11 +310,9 @@ export async function recordBurn(
|
|
|
294
310
|
tokens: Map<bigint, number[]>,
|
|
295
311
|
signal?: AbortSignal,
|
|
296
312
|
): Promise<DepositRecord> {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
const { salt, address } = await burnAddress(ctx.seed, chainId, tokenId, index, signal);
|
|
313
|
+
const index = toRecord(ctx, chainId, tokenId);
|
|
314
|
+
const key = burnKey(ctx.view.seed);
|
|
315
|
+
const { salt, address } = await burnAddress(key, chainId, tokenId, index, owner(ctx)!.publicKey, signal);
|
|
302
316
|
const { commitmentsFinal: from } = await chainCtx(ctx, chainId).indexer.status();
|
|
303
317
|
const block = { chainId, tokenId, first: index, salts: [hexToBytes(salt)] };
|
|
304
318
|
const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planBurnBlock(ctx.view, s, block));
|
|
@@ -370,11 +384,43 @@ function upsertNote(ctx: Ctx, a: NewRecord, note: Note, tokens: Map<bigint, numb
|
|
|
370
384
|
peer,
|
|
371
385
|
atNs,
|
|
372
386
|
...(keyIndex === undefined ? {} : { keyIndex }),
|
|
373
|
-
|
|
387
|
+
// storage tells of a note once: one whose key waits for the seed is kept, not dropped
|
|
388
|
+
...(watchFor(ctx, chainId, tokenId, big(value), salt, keyIndex, from) ?? {
|
|
389
|
+
unkeyed: true,
|
|
390
|
+
noteHash: '0x',
|
|
391
|
+
watch: null,
|
|
392
|
+
}),
|
|
374
393
|
found: [],
|
|
375
394
|
});
|
|
376
395
|
}
|
|
377
396
|
|
|
397
|
+
/** Receive codes of each token whose keys are derived while the seed is held, to hand out while it is not. */
|
|
398
|
+
export const CODES_AHEAD = 4;
|
|
399
|
+
|
|
400
|
+
/** With the seed held, each owner not derived yet: the wallet's own, each of its burn addresses' (a receive code each),
|
|
401
|
+
* the next `CODES_AHEAD` codes' of each of `tokens`, and each note's key's; and the watch of each note that waited for
|
|
402
|
+
* its key. */
|
|
403
|
+
export function keyAll(ctx: Ctx, tokens: Token[]): void {
|
|
404
|
+
owner(ctx);
|
|
405
|
+
// ponytail: one derivation per own burn address as the wallet opens; keep owners in the saved state if wallets grow
|
|
406
|
+
// to thousands of them
|
|
407
|
+
for (const d of ctx.state.deposits) {
|
|
408
|
+
if (d.burnIndex >= 0 && !d.managed) owner(ctx, { chainId: d.chainId, tokenId: d.tokenId, index: d.burnIndex });
|
|
409
|
+
}
|
|
410
|
+
for (const { chainId, tokenId } of tokens) {
|
|
411
|
+
const next = nextBurn(ctx, chainId, tokenId);
|
|
412
|
+
for (let index = next; index < next + CODES_AHEAD; index++) owner(ctx, { chainId, tokenId, index });
|
|
413
|
+
}
|
|
414
|
+
for (const n of ctx.state.notes) {
|
|
415
|
+
if (n.keyIndex === undefined) continue;
|
|
416
|
+
owner(ctx, { chainId: n.chainId, tokenId: n.tokenId, index: n.keyIndex });
|
|
417
|
+
if (!n.unkeyed) continue;
|
|
418
|
+
const from = commitmentsAt(chainState(ctx, n.chainId).samples, Number((n.atNs ?? 0n) / 1_000_000n) - DAY_MS);
|
|
419
|
+
Object.assign(n, watchFor(ctx, n.chainId, n.tokenId, n.value, n.salt, n.keyIndex, from));
|
|
420
|
+
delete n.unkeyed;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
378
424
|
/** Whether something was started or announced recently enough to look past the final count for it. */
|
|
379
425
|
function waiting(ctx: Ctx, chainId: bigint, now: number): boolean {
|
|
380
426
|
const recent = (ms: number | null) => ms !== null && now - ms < WAIT_MS;
|
|
@@ -384,7 +430,12 @@ function waiting(ctx: Ctx, chainId: bigint, now: number): boolean {
|
|
|
384
430
|
(d) => d.chainId === chainId && d.found.length === 0 && recent(d.createdMs),
|
|
385
431
|
) ||
|
|
386
432
|
ctx.state.notes.some(
|
|
387
|
-
(n) =>
|
|
433
|
+
(n) =>
|
|
434
|
+
n.chainId === chainId &&
|
|
435
|
+
!n.unkeyed &&
|
|
436
|
+
n.found.length === 0 &&
|
|
437
|
+
n.atNs !== null &&
|
|
438
|
+
recent(Number(n.atNs / 1_000_000n)),
|
|
388
439
|
)
|
|
389
440
|
);
|
|
390
441
|
}
|
|
@@ -476,12 +527,13 @@ async function syncChain(ctx: Ctx, c: ChainCtx, tokens: number[]): Promise<void>
|
|
|
476
527
|
...deposits.flatMap((d) => d.found.map((f) => ({ f, code: undefined }))),
|
|
477
528
|
].filter(({ f }) => !f.tentative && !f.spent);
|
|
478
529
|
for (const { f, code } of owned) {
|
|
479
|
-
|
|
480
|
-
|
|
530
|
+
const key = owner(ctx, code);
|
|
531
|
+
if (f.nullifier || !key) continue; // without its owner: looked for once the seed is held
|
|
532
|
+
const nullifier = core.nullifier(chainId, BigInt(f.index), key.nullifyingKey);
|
|
481
533
|
const from = await nullifierFrom(f.index);
|
|
482
534
|
f.nullifier = { key: nullifier.slice(0, 18) as Hex, from, scannedTo: from };
|
|
483
535
|
}
|
|
484
|
-
const nullifierWatches = owned.
|
|
536
|
+
const nullifierWatches = owned.flatMap(({ f }) => (f.nullifier ? [f.nullifier] : []));
|
|
485
537
|
const spent = await scan(
|
|
486
538
|
nullifierWatches,
|
|
487
539
|
8,
|
|
@@ -489,13 +541,32 @@ async function syncChain(ctx: Ctx, c: ChainCtx, tokens: number[]): Promise<void>
|
|
|
489
541
|
status.nullifiersFinal,
|
|
490
542
|
live === status.commitments ? status.nullifiers : status.nullifiersFinal,
|
|
491
543
|
);
|
|
492
|
-
|
|
493
|
-
|
|
544
|
+
applySpends(
|
|
545
|
+
[...notes, ...deposits].flatMap((r): Position[] => r.found),
|
|
546
|
+
spent,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Records nullifier hits on the positions they spend: a final one spends it, one past the final count marks it
|
|
551
|
+
* `nullifiedAt`, until the final nullifiers pass that place without it (a reorg dropped that JoinSplit, which shows up
|
|
552
|
+
* again where it went). Every device sees an input gone once its JoinSplit is in a block, as the one that sent it does. */
|
|
553
|
+
export function applySpends(positions: Position[], hits: Hit[]): void {
|
|
554
|
+
const byNullifier = new Map(positions.filter((f) => f.nullifier).map((f) => [f.nullifier!.key, f]));
|
|
555
|
+
for (const hit of hits) {
|
|
494
556
|
const f = byNullifier.get(hit.key);
|
|
495
|
-
if (f
|
|
557
|
+
if (!f) continue;
|
|
558
|
+
if (hit.tentative) f.nullifiedAt = hit.position;
|
|
559
|
+
else f.spent = true;
|
|
560
|
+
}
|
|
561
|
+
for (const f of positions) {
|
|
562
|
+
// a spent position needs no more watching
|
|
563
|
+
if (f.spent) {
|
|
564
|
+
delete f.nullifier;
|
|
565
|
+
delete f.nullifiedAt;
|
|
566
|
+
} else if (f.nullifier && f.nullifiedAt !== undefined && f.nullifiedAt < f.nullifier.scannedTo) {
|
|
567
|
+
delete f.nullifiedAt;
|
|
568
|
+
}
|
|
496
569
|
}
|
|
497
|
-
// a spent position needs no more watching
|
|
498
|
-
for (const r of [...notes, ...deposits]) for (const f of r.found) if (f.spent) delete f.nullifier;
|
|
499
570
|
}
|
|
500
571
|
|
|
501
572
|
/** The four fields of the commitment at an index, read from its page of commitments, each page once per reader: `null`
|