@utxopia/sdk 0.1.0-alpha.2 → 0.1.0-alpha.3
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/README.md +214 -108
- package/{packages/sdk/dist → dist}/client.d.ts +25 -1
- package/{packages/sdk/dist → dist}/client.js +36 -1
- package/{packages/sdk/dist → dist}/crypto-ed25519.d.ts +14 -0
- package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
- package/{packages/sdk/dist → dist}/index.d.ts +3 -3
- package/{packages/sdk/dist → dist}/index.js +3 -3
- package/{packages/sdk/dist → dist}/instructions.d.ts +28 -11
- package/{packages/sdk/dist → dist}/instructions.js +45 -15
- package/{packages/sdk/dist → dist}/stealth.d.ts +104 -9
- package/{packages/sdk/dist → dist}/stealth.js +146 -14
- package/{packages/sdk/dist → dist}/taproot.d.ts +39 -2
- package/{packages/sdk/dist → dist}/taproot.js +54 -2
- package/package.json +86 -63
- package/src/announcement-client.ts +457 -0
- package/src/auditor-ciphertext.ts +181 -0
- package/src/auditor.ts +409 -0
- package/src/bitcoin/ika.ts +103 -0
- package/src/bitcoin/index.ts +5 -0
- package/src/bound-params.ts +322 -0
- package/src/chadbuffer.ts +603 -0
- package/src/circomlibjs.d.ts +51 -0
- package/src/claim-link.ts +53 -0
- package/src/client.ts +638 -0
- package/src/commitment-tree.ts +736 -0
- package/src/config.ts +772 -0
- package/src/core/esplora.ts +332 -0
- package/src/core/mempool.ts +159 -0
- package/src/crypto-babyjub.ts +385 -0
- package/src/crypto-ed25519.ts +297 -0
- package/src/crypto.ts +199 -0
- package/src/event-client.ts +231 -0
- package/src/events.ts +384 -0
- package/src/explorer.ts +300 -0
- package/src/index.ts +902 -0
- package/src/instructions.ts +2820 -0
- package/src/keys.ts +1228 -0
- package/src/logger.ts +41 -0
- package/src/magicblock.ts +278 -0
- package/src/merkle.ts +197 -0
- package/src/note.ts +754 -0
- package/src/pda.ts +516 -0
- package/src/pool-state.ts +176 -0
- package/src/poseidon.ts +175 -0
- package/src/prover/index.ts +19 -0
- package/src/prover/mobile.ts +303 -0
- package/src/prover/web.ts +771 -0
- package/src/psbt.ts +333 -0
- package/src/selective-disclosure.ts +284 -0
- package/src/sender-memo.ts +343 -0
- package/src/snarkjs.d.ts +19 -0
- package/src/sns-resolver.ts +333 -0
- package/src/solana/connection.ts +189 -0
- package/src/solana/priority-fee.ts +201 -0
- package/src/spend-doc.ts +163 -0
- package/src/stealth.ts +1477 -0
- package/src/taproot.ts +707 -0
- package/src/token-registry.ts +207 -0
- package/src/utils/encoding.ts +33 -0
- package/src/vk-registry.ts +295 -0
- package/LICENSE +0 -21
- package/packages/btc-client/src/esplora-client.ts +0 -153
- package/packages/btc-client/src/index.ts +0 -3
- package/packages/btc-client/src/op-return.ts +0 -93
- package/packages/btc-client/src/types.ts +0 -112
- package/packages/sdk/README.md +0 -277
- /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
- /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
- /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/auditor.js +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
- /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
- /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
- /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
- /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
- /package/{packages/sdk/dist → dist}/config.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/config.js +0 -0
- /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
- /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
- /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
- /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/crypto.js +0 -0
- /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/event-client.js +0 -0
- /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/events.js +0 -0
- /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/explorer.js +0 -0
- /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/keys.js +0 -0
- /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/logger.js +0 -0
- /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
- /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/merkle.js +0 -0
- /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/note.js +0 -0
- /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/pda.js +0 -0
- /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
- /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
- /package/{packages/sdk/dist → dist}/psbt.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/psbt.js +0 -0
- /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
- /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
- /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
- /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
- /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
- /package/{packages/sdk/dist → dist}/spend-doc.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/spend-doc.js +0 -0
- /package/{packages/sdk/dist → dist}/token-registry.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/token-registry.js +0 -0
- /package/{packages/sdk/dist → dist}/utils/encoding.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/utils/encoding.js +0 -0
- /package/{packages/sdk/dist → dist}/vk-registry.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/vk-registry.js +0 -0
package/src/stealth.ts
ADDED
|
@@ -0,0 +1,1477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth address utilities for UTXOPIA
|
|
3
|
+
*
|
|
4
|
+
* Dual-curve stealth flow (Railgun-style):
|
|
5
|
+
*
|
|
6
|
+
* Stealth Deposit Flow:
|
|
7
|
+
* ```
|
|
8
|
+
* Sender:
|
|
9
|
+
* 1. ephemeral = random Ed25519 keypair
|
|
10
|
+
* 2. sharedSecret = X25519(ephemeral.priv, recipientViewingPub)
|
|
11
|
+
* 3. stealthScalar = SHA256(sharedSecret || domain) mod BJJ_ORDER
|
|
12
|
+
* 4. stealthPub = spendingPub + stealthScalar × BASE8 (Baby Jubjub)
|
|
13
|
+
* 5. commitment = Poseidon(stealthPub.x, amount)
|
|
14
|
+
* 6. encryptedAmount = amount XOR sha256(sharedSecret)[0..8]
|
|
15
|
+
*
|
|
16
|
+
* Recipient (viewing key only - can detect and see amount):
|
|
17
|
+
* 1. sharedSecret = X25519(viewingPriv, ephemeralPub)
|
|
18
|
+
* 2. amount = encryptedAmount XOR sha256(sharedSecret)[0..8]
|
|
19
|
+
* 3. stealthPub = spendingPub + stealthScalar × BASE8
|
|
20
|
+
* 4. Verify: commitment == Poseidon(stealthPub.x, amount)
|
|
21
|
+
*
|
|
22
|
+
* Recipient (spending key - can claim):
|
|
23
|
+
* 1. stealthPriv = spendingPriv + stealthScalar (mod BJJ_ORDER)
|
|
24
|
+
* 2. nullifier = Poseidon(stealthPriv, leafIndex)
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Format (90 bytes on-chain):
|
|
28
|
+
* - ephemeral_pub (32 bytes) - Ed25519 public key
|
|
29
|
+
* - encrypted_amount (8 bytes) - XOR encrypted with shared secret
|
|
30
|
+
* - commitment (32 bytes) - Poseidon hash for Merkle tree
|
|
31
|
+
* - leaf_index (8 bytes) - Position in Merkle tree
|
|
32
|
+
* - created_at (8 bytes) - Timestamp
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ========== Constants (defined before imports to ensure availability) ==========
|
|
36
|
+
|
|
37
|
+
/** Announcement type: deposit (plaintext amount) */
|
|
38
|
+
export const ANNOUNCEMENT_TYPE_DEPOSIT = 0;
|
|
39
|
+
|
|
40
|
+
/** Announcement type: transfer (XOR-encrypted amount) */
|
|
41
|
+
export const ANNOUNCEMENT_TYPE_TRANSFER = 1;
|
|
42
|
+
|
|
43
|
+
function isKnownAnnouncementType(value: number): boolean {
|
|
44
|
+
return value === ANNOUNCEMENT_TYPE_DEPOSIT || value === ANNOUNCEMENT_TYPE_TRANSFER;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ========== Imports ==========
|
|
48
|
+
|
|
49
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
50
|
+
import {
|
|
51
|
+
bigintToBytes,
|
|
52
|
+
bytesToBigint,
|
|
53
|
+
bytesToHex,
|
|
54
|
+
hexToBytes,
|
|
55
|
+
BN254_FIELD_PRIME,
|
|
56
|
+
babyJubMul,
|
|
57
|
+
babyJubAdd,
|
|
58
|
+
babyJubCompress,
|
|
59
|
+
babyJubDecompress,
|
|
60
|
+
BABYJUB_BASE8,
|
|
61
|
+
BABYJUB_ORDER,
|
|
62
|
+
scalarFromBytes,
|
|
63
|
+
type BabyJubPoint,
|
|
64
|
+
} from "./crypto";
|
|
65
|
+
import {
|
|
66
|
+
ed25519GenerateKeyPair,
|
|
67
|
+
ed25519KeyPairFromMaterial,
|
|
68
|
+
x25519Ecdh,
|
|
69
|
+
encryptAmountEd25519,
|
|
70
|
+
decryptAmountEd25519,
|
|
71
|
+
} from "./crypto-ed25519";
|
|
72
|
+
import type { StealthMetaAddress, UTXOpiaKeys, WalletSignerAdapter } from "./keys";
|
|
73
|
+
import { deriveKeysFromWallet, parseStealthMetaAddress, constantTimeCompare } from "./keys";
|
|
74
|
+
import {
|
|
75
|
+
poseidonHashSync,
|
|
76
|
+
computeNullifierSync as poseidonComputeNullifier,
|
|
77
|
+
computeMPKSync,
|
|
78
|
+
computeNPKSync,
|
|
79
|
+
computeJoinSplitCommitmentSync,
|
|
80
|
+
computeJoinSplitNullifierSync,
|
|
81
|
+
} from "./poseidon";
|
|
82
|
+
import { getConfig } from "./config";
|
|
83
|
+
import { deriveRawXOnlyP2TRAddress } from "./bitcoin/ika";
|
|
84
|
+
import type { DepositOpReturnContext } from "./taproot";
|
|
85
|
+
|
|
86
|
+
// ========== Amount Encryption Helpers ==========
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Encrypt amount using XOR with shared secret
|
|
90
|
+
*/
|
|
91
|
+
export function encryptAmount(amount: bigint, sharedSecret: Uint8Array): Uint8Array {
|
|
92
|
+
return encryptAmountEd25519(amount, sharedSecret);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decrypt amount using XOR with shared secret
|
|
97
|
+
*/
|
|
98
|
+
export function decryptAmount(encryptedAmount: Uint8Array, sharedSecret: Uint8Array): bigint {
|
|
99
|
+
return decryptAmountEd25519(encryptedAmount, sharedSecret);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Re-export combined note data encryption
|
|
103
|
+
export { encryptNoteData, decryptNoteData } from "./crypto-ed25519";
|
|
104
|
+
|
|
105
|
+
// ========== Type Guard ==========
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Type guard to distinguish between WalletSignerAdapter and UTXOpiaKeys
|
|
109
|
+
*/
|
|
110
|
+
export function isWalletAdapter(source: unknown): source is WalletSignerAdapter {
|
|
111
|
+
return (
|
|
112
|
+
typeof source === "object" &&
|
|
113
|
+
source !== null &&
|
|
114
|
+
"signMessage" in source &&
|
|
115
|
+
typeof (source as WalletSignerAdapter).signMessage === "function"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ========== Types ==========
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Stealth Deposit with single Ed25519 ephemeral key
|
|
123
|
+
*/
|
|
124
|
+
export interface StealthDeposit {
|
|
125
|
+
/** Ed25519 ephemeral public key (32 bytes) */
|
|
126
|
+
ephemeralPub: Uint8Array;
|
|
127
|
+
|
|
128
|
+
/** Encrypted amount (8 bytes) */
|
|
129
|
+
encryptedAmount: Uint8Array;
|
|
130
|
+
|
|
131
|
+
/** Commitment for Merkle tree (32 bytes) - Poseidon(stealthPub.x, amount) */
|
|
132
|
+
commitment: Uint8Array;
|
|
133
|
+
|
|
134
|
+
/** Unix timestamp when created */
|
|
135
|
+
createdAt: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Scanned note from announcement (viewing key can detect)
|
|
140
|
+
*/
|
|
141
|
+
export interface ScannedNote {
|
|
142
|
+
/** Amount in satoshis */
|
|
143
|
+
amount: bigint;
|
|
144
|
+
|
|
145
|
+
/** Ed25519 ephemeral public key (needed for shared secret) */
|
|
146
|
+
ephemeralPub: Uint8Array;
|
|
147
|
+
|
|
148
|
+
/** Computed stealth public key (Baby Jubjub) */
|
|
149
|
+
stealthPub: BabyJubPoint;
|
|
150
|
+
|
|
151
|
+
/** Leaf index in Merkle tree */
|
|
152
|
+
leafIndex: number;
|
|
153
|
+
|
|
154
|
+
/** Original announcement commitment */
|
|
155
|
+
commitment: Uint8Array;
|
|
156
|
+
|
|
157
|
+
/** Unix timestamp (seconds) from on-chain block_time, 0 if unavailable */
|
|
158
|
+
blockTime?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Prepared claim inputs for JoinSplit ZK proof (requires spending key)
|
|
163
|
+
*/
|
|
164
|
+
export interface ClaimInputs {
|
|
165
|
+
stealthPrivKey: bigint;
|
|
166
|
+
nullifyingKey: bigint;
|
|
167
|
+
amount: bigint;
|
|
168
|
+
leafIndex: number;
|
|
169
|
+
merklePath: bigint[];
|
|
170
|
+
merkleIndices: number[];
|
|
171
|
+
merkleRoot: bigint;
|
|
172
|
+
nullifier: bigint;
|
|
173
|
+
npk: bigint;
|
|
174
|
+
random: bigint;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ========== On-chain Announcement ==========
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Parsed stealth announcement from on-chain data
|
|
181
|
+
*/
|
|
182
|
+
export interface OnChainStealthAnnouncement {
|
|
183
|
+
/** 0 = deposit (plaintext amount), 1 = transfer (encrypted amount) */
|
|
184
|
+
announcementType: number;
|
|
185
|
+
ephemeralPub: Uint8Array;
|
|
186
|
+
/** Raw amount bytes: plaintext if type=0, encrypted if type=1 */
|
|
187
|
+
encryptedAmount: Uint8Array;
|
|
188
|
+
/** Commitment = Poseidon(npk, token, amount) stored on-chain */
|
|
189
|
+
commitment: Uint8Array;
|
|
190
|
+
leafIndex: number;
|
|
191
|
+
/** Unix timestamp (seconds) from on-chain block_time, 0 if unavailable */
|
|
192
|
+
blockTime?: number;
|
|
193
|
+
/** Solana slot the announcement was emitted in. Needed for auditor slot-range scoping. */
|
|
194
|
+
slot?: number;
|
|
195
|
+
/** Token id hex from the backend indexer, when available. */
|
|
196
|
+
tokenIdHex?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ========== Helper Functions ==========
|
|
200
|
+
|
|
201
|
+
/** Domain separator for stealth key derivation.
|
|
202
|
+
* "Utxopia-stealth-v1" is LOAD-BEARING — once real stealth addresses exist this
|
|
203
|
+
* exact byte sequence is FROZEN; sender and receiver must use the identical
|
|
204
|
+
* value or payments won't be detected. Bump the suffix only as a deliberate
|
|
205
|
+
* v2 break. */
|
|
206
|
+
const STEALTH_KEY_DOMAIN = new TextEncoder().encode("Utxopia-stealth-v1");
|
|
207
|
+
|
|
208
|
+
// tokenId removed — use computeTokenId(mintBytes) from poseidon.ts instead
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Derive stealth scalar from X25519 shared secret
|
|
212
|
+
*
|
|
213
|
+
* stealthScalar = SHA256(sharedSecret || domain) mod BJJ_ORDER
|
|
214
|
+
*/
|
|
215
|
+
function deriveStealthScalar(sharedSecret: Uint8Array): bigint {
|
|
216
|
+
const hashInput = new Uint8Array(sharedSecret.length + STEALTH_KEY_DOMAIN.length);
|
|
217
|
+
hashInput.set(sharedSecret, 0);
|
|
218
|
+
hashInput.set(STEALTH_KEY_DOMAIN, sharedSecret.length);
|
|
219
|
+
|
|
220
|
+
const hash = sha256(hashInput);
|
|
221
|
+
return scalarFromBytes(hash);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Derive stealth public key (Baby Jubjub)
|
|
226
|
+
*
|
|
227
|
+
* stealthPub = spendingPub + stealthScalar × BASE8
|
|
228
|
+
*/
|
|
229
|
+
function deriveStealthPubKey(
|
|
230
|
+
spendingPub: BabyJubPoint,
|
|
231
|
+
sharedSecret: Uint8Array
|
|
232
|
+
): BabyJubPoint {
|
|
233
|
+
const scalar = deriveStealthScalar(sharedSecret);
|
|
234
|
+
const scalarPoint = babyJubMul(scalar, BABYJUB_BASE8);
|
|
235
|
+
return babyJubAdd(spendingPub, scalarPoint);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Derive stealth private key (Baby Jubjub scalar addition)
|
|
240
|
+
*
|
|
241
|
+
* stealthPriv = spendingPriv + stealthScalar (mod BJJ_ORDER)
|
|
242
|
+
*/
|
|
243
|
+
function deriveStealthPrivKey(
|
|
244
|
+
spendingPriv: bigint,
|
|
245
|
+
sharedSecret: Uint8Array
|
|
246
|
+
): bigint {
|
|
247
|
+
const scalar = deriveStealthScalar(sharedSecret);
|
|
248
|
+
return (spendingPriv + scalar) % BABYJUB_ORDER;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ========== Sender Functions ==========
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create a stealth deposit (JoinSplit-compatible)
|
|
255
|
+
*
|
|
256
|
+
* 1. Generate Ed25519 ephemeral keypair
|
|
257
|
+
* 2. sharedSecret = X25519(ephemeral.priv, viewingPub)
|
|
258
|
+
* 3. stealthPub = spendingPub + hash(sharedSecret) × BASE8
|
|
259
|
+
* 4. stealthMPK = Poseidon(stealthPub.x, stealthPub.y, nullifyingKey)
|
|
260
|
+
* (sender uses recipientMPK from meta-address for stealth deposits)
|
|
261
|
+
* 5. npk = Poseidon(recipientMPK, random)
|
|
262
|
+
* 6. commitment = Poseidon(npk, tokenId, amount)
|
|
263
|
+
* 7. encryptedAmount = amount XOR sha256(sharedSecret)[0..8]
|
|
264
|
+
*/
|
|
265
|
+
export async function createStealthDeposit(
|
|
266
|
+
recipientMeta: StealthMetaAddress,
|
|
267
|
+
amountSats: bigint,
|
|
268
|
+
tokenId: bigint,
|
|
269
|
+
outgoing?: OutgoingRecoveryMaterial,
|
|
270
|
+
): Promise<StealthDeposit> {
|
|
271
|
+
// Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
|
|
272
|
+
const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
|
|
273
|
+
|
|
274
|
+
// Indexed off the sender's outgoing node when supplied, so they can recompute
|
|
275
|
+
// this payment later; random otherwise, which works but leaves no trace the
|
|
276
|
+
// sender can recover. Optional rather than required because the cost of
|
|
277
|
+
// omitting it is a lost record, not lost funds — unlike a deposit address,
|
|
278
|
+
// where the ephemeral key IS the only way to ever spend the coins.
|
|
279
|
+
const ephemeral = outgoing
|
|
280
|
+
? outgoingEphemeralKeyPair(outgoing)
|
|
281
|
+
: ed25519GenerateKeyPair();
|
|
282
|
+
|
|
283
|
+
// X25519 ECDH: shared secret
|
|
284
|
+
const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
|
|
285
|
+
|
|
286
|
+
// Derive stealth scalar as the random value for NPK
|
|
287
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
288
|
+
|
|
289
|
+
// Use recipient's MPK from meta-address to compute NPK
|
|
290
|
+
const recipientMPK = bytesToBigint(recipientMeta.mpk);
|
|
291
|
+
const npk = computeNPKSync(recipientMPK, stealthScalar);
|
|
292
|
+
|
|
293
|
+
// Compute JoinSplit commitment = Poseidon(npk, token, amount)
|
|
294
|
+
const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
|
|
295
|
+
const commitment = bigintToBytes(commitmentBigint);
|
|
296
|
+
|
|
297
|
+
// Encrypt amount
|
|
298
|
+
const encryptedAmount = encryptAmount(amountSats, sharedSecret);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
ephemeralPub: new Uint8Array(ephemeral.pubKey),
|
|
302
|
+
encryptedAmount,
|
|
303
|
+
commitment,
|
|
304
|
+
createdAt: Date.now(),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Extended stealth output data including the derived stealth pub key
|
|
310
|
+
*/
|
|
311
|
+
export interface StealthOutputWithKeys extends StealthOutputData {
|
|
312
|
+
stealthPubKeyX: bigint;
|
|
313
|
+
/** npk as 32-byte LE Uint8Array — ready for on-chain instruction data */
|
|
314
|
+
npkBytes: Uint8Array;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Create stealth deposit with npk for JoinSplit circuit input
|
|
319
|
+
*/
|
|
320
|
+
export async function createStealthDepositWithKeys(
|
|
321
|
+
recipientMeta: StealthMetaAddress,
|
|
322
|
+
amountSats: bigint,
|
|
323
|
+
tokenId: bigint,
|
|
324
|
+
): Promise<StealthOutputWithKeys> {
|
|
325
|
+
// Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
|
|
326
|
+
const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
|
|
327
|
+
|
|
328
|
+
const ephemeral = ed25519GenerateKeyPair();
|
|
329
|
+
const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
|
|
330
|
+
|
|
331
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
332
|
+
const recipientMPK = bytesToBigint(recipientMeta.mpk);
|
|
333
|
+
const npk = computeNPKSync(recipientMPK, stealthScalar);
|
|
334
|
+
|
|
335
|
+
const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
|
|
336
|
+
const commitment = bigintToBytes(commitmentBigint);
|
|
337
|
+
const encryptedAmount = encryptAmount(amountSats, sharedSecret);
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
ephemeralPub: new Uint8Array(ephemeral.pubKey),
|
|
341
|
+
encryptedAmount,
|
|
342
|
+
commitment,
|
|
343
|
+
stealthPubKeyX: npk,
|
|
344
|
+
npkBytes: bigintToBytes(npk),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ========== Non-Interactive Deposit (OP_RETURN) ==========
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Result of a non-interactive deposit preparation.
|
|
352
|
+
* Contains everything needed to build a PSBT with an OP_RETURN output.
|
|
353
|
+
*
|
|
354
|
+
* npk-based flow: user can send any amount of BTC. The commitment is
|
|
355
|
+
* computed on-chain from npk + actual amount.
|
|
356
|
+
*/
|
|
357
|
+
export interface NonInteractiveDepositResult {
|
|
358
|
+
/** Taproot address to send BTC to */
|
|
359
|
+
btcAddress: string;
|
|
360
|
+
/** 32-byte x-only output key for the deposit P2TR output */
|
|
361
|
+
depositOutputKey: Uint8Array;
|
|
362
|
+
/** 73-byte OP_RETURN payload: header || poolTag || ephemeralPub || npk */
|
|
363
|
+
opReturnPayload: Uint8Array;
|
|
364
|
+
/** 32-byte note public key (for tracking) */
|
|
365
|
+
npk: Uint8Array;
|
|
366
|
+
/** 32-byte Ed25519 ephemeral public key */
|
|
367
|
+
ephemeralPub: Uint8Array;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Extended result when a user refund pubkey is provided.
|
|
372
|
+
* Includes Taproot script-path data for the refund spending path.
|
|
373
|
+
*/
|
|
374
|
+
export interface NonInteractiveDepositWithRefundResult extends NonInteractiveDepositResult {
|
|
375
|
+
/** 32-byte Merkle root (TapLeaf hash of the refund script) */
|
|
376
|
+
merkleRoot: Uint8Array;
|
|
377
|
+
/** 33-byte control block for script-path spend (leaf_version|parity + internal_key) */
|
|
378
|
+
controlBlock: Uint8Array;
|
|
379
|
+
/** 73-byte refund script */
|
|
380
|
+
refundScript: Uint8Array;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Create a non-interactive stealth deposit (npk-based).
|
|
385
|
+
*
|
|
386
|
+
* This is the client-side-only deposit flow: no backend API call needed.
|
|
387
|
+
* The ephemeral key and npk are embedded in the BTC transaction's OP_RETURN
|
|
388
|
+
* output so the backend can passively detect them.
|
|
389
|
+
*
|
|
390
|
+
* The user can send ANY amount of BTC — the commitment is computed on-chain
|
|
391
|
+
* from the npk + actual BTC amount received.
|
|
392
|
+
*
|
|
393
|
+
* When `userRefundPubkey` is provided, the Taproot address includes a
|
|
394
|
+
* script-path with a time-locked refund spending condition (144 blocks).
|
|
395
|
+
*
|
|
396
|
+
* @param recipientMeta - Recipient's stealth meta-address
|
|
397
|
+
* @param custodyInternalKey - Pool custody x-only pubkey (32 bytes), used as the Taproot internal key
|
|
398
|
+
* @param network - Bitcoin network for address encoding
|
|
399
|
+
* @param userRefundPubkey - Optional 32-byte x-only pubkey for refund script path
|
|
400
|
+
*/
|
|
401
|
+
export async function createNonInteractiveDeposit(
|
|
402
|
+
recipientMeta: StealthMetaAddress,
|
|
403
|
+
custodyInternalKey: Uint8Array,
|
|
404
|
+
network?: "mainnet" | "testnet" | "regtest",
|
|
405
|
+
userRefundPubkey?: undefined,
|
|
406
|
+
opReturnContext?: DepositOpReturnContext,
|
|
407
|
+
): Promise<NonInteractiveDepositResult>;
|
|
408
|
+
export async function createNonInteractiveDeposit(
|
|
409
|
+
recipientMeta: StealthMetaAddress,
|
|
410
|
+
custodyInternalKey: Uint8Array,
|
|
411
|
+
network: "mainnet" | "testnet" | "regtest",
|
|
412
|
+
userRefundPubkey: Uint8Array,
|
|
413
|
+
opReturnContext: DepositOpReturnContext,
|
|
414
|
+
): Promise<NonInteractiveDepositWithRefundResult>;
|
|
415
|
+
export async function createNonInteractiveDeposit(
|
|
416
|
+
recipientMeta: StealthMetaAddress,
|
|
417
|
+
custodyInternalKey: Uint8Array,
|
|
418
|
+
network: "mainnet" | "testnet" | "regtest" = "testnet",
|
|
419
|
+
userRefundPubkey?: Uint8Array,
|
|
420
|
+
opReturnContext?: DepositOpReturnContext,
|
|
421
|
+
): Promise<NonInteractiveDepositResult | NonInteractiveDepositWithRefundResult> {
|
|
422
|
+
if (!opReturnContext) {
|
|
423
|
+
throw new Error("deposit OP_RETURN context is required");
|
|
424
|
+
}
|
|
425
|
+
// Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
|
|
426
|
+
const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
|
|
427
|
+
|
|
428
|
+
// 1. Generate ephemeral Ed25519 keypair
|
|
429
|
+
const ephemeral = ed25519GenerateKeyPair();
|
|
430
|
+
|
|
431
|
+
// 2. X25519 ECDH shared secret
|
|
432
|
+
const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
|
|
433
|
+
|
|
434
|
+
// 3. Derive stealth scalar → NPK (no commitment — computed on-chain)
|
|
435
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
436
|
+
const recipientMPK = bytesToBigint(recipientMeta.mpk);
|
|
437
|
+
const npkBigint = computeNPKSync(recipientMPK, stealthScalar);
|
|
438
|
+
const npk = bigintToBytes(npkBigint);
|
|
439
|
+
|
|
440
|
+
const ephemeralPub = new Uint8Array(ephemeral.pubKey);
|
|
441
|
+
|
|
442
|
+
if (userRefundPubkey) {
|
|
443
|
+
// Refund path: Taproot with script tree containing refund script
|
|
444
|
+
const { deriveTaprootAddressWithRefund, buildDepositOpReturn } = await import("./taproot");
|
|
445
|
+
const {
|
|
446
|
+
address: btcAddress,
|
|
447
|
+
outputKey,
|
|
448
|
+
merkleRoot,
|
|
449
|
+
controlBlock,
|
|
450
|
+
refundScript,
|
|
451
|
+
} = deriveTaprootAddressWithRefund(npk, userRefundPubkey, custodyInternalKey, network);
|
|
452
|
+
|
|
453
|
+
// Still build OP_RETURN so the backend can detect the deposit
|
|
454
|
+
const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
btcAddress,
|
|
458
|
+
depositOutputKey: outputKey,
|
|
459
|
+
opReturnPayload,
|
|
460
|
+
npk,
|
|
461
|
+
ephemeralPub,
|
|
462
|
+
merkleRoot,
|
|
463
|
+
controlBlock,
|
|
464
|
+
refundScript,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Standard path: key-path-only Taproot address
|
|
469
|
+
const { deriveTaprootAddress, buildDepositOpReturn } = await import("./taproot");
|
|
470
|
+
const { address: btcAddress, outputKey } = deriveTaprootAddress(npk, network, custodyInternalKey);
|
|
471
|
+
|
|
472
|
+
const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
btcAddress,
|
|
476
|
+
depositOutputKey: outputKey,
|
|
477
|
+
opReturnPayload,
|
|
478
|
+
npk,
|
|
479
|
+
ephemeralPub,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Create a non-interactive deposit directly to an Ika-controlled vault.
|
|
485
|
+
*
|
|
486
|
+
* The BTC address is the raw Ika x-only Taproot witness program, so Ika can
|
|
487
|
+
* later sign and spend the UTXO. Privacy/ownership metadata stays per-deposit
|
|
488
|
+
* in OP_RETURN(header || poolTag || ephemeralPub || npk), and the destination chain
|
|
489
|
+
* credits the note from that transaction.
|
|
490
|
+
*/
|
|
491
|
+
export async function createDirectVaultDeposit(
|
|
492
|
+
recipientMeta: StealthMetaAddress,
|
|
493
|
+
vaultXOnlyPubkey: Uint8Array,
|
|
494
|
+
network: "mainnet" | "testnet" | "regtest" = "testnet",
|
|
495
|
+
opReturnContext?: DepositOpReturnContext,
|
|
496
|
+
): Promise<NonInteractiveDepositResult> {
|
|
497
|
+
if (!opReturnContext) {
|
|
498
|
+
throw new Error("deposit OP_RETURN context is required");
|
|
499
|
+
}
|
|
500
|
+
if (vaultXOnlyPubkey.length !== 32) {
|
|
501
|
+
throw new Error("vaultXOnlyPubkey must be 32 bytes");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
|
|
505
|
+
const ephemeral = ed25519GenerateKeyPair();
|
|
506
|
+
const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
|
|
507
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
508
|
+
const recipientMPK = bytesToBigint(recipientMeta.mpk);
|
|
509
|
+
const npkBigint = computeNPKSync(recipientMPK, stealthScalar);
|
|
510
|
+
const npk = bigintToBytes(npkBigint);
|
|
511
|
+
const ephemeralPub = new Uint8Array(ephemeral.pubKey);
|
|
512
|
+
|
|
513
|
+
const { buildDepositOpReturn } = await import("./taproot");
|
|
514
|
+
const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
btcAddress: deriveRawXOnlyP2TRAddress(vaultXOnlyPubkey, network),
|
|
518
|
+
depositOutputKey: vaultXOnlyPubkey,
|
|
519
|
+
opReturnPayload,
|
|
520
|
+
npk,
|
|
521
|
+
ephemeralPub,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const DEPOSIT_VIEWING_NODE_DOMAIN = new TextEncoder().encode("utxopia:deposit-viewing-node:v1");
|
|
526
|
+
const DEPOSIT_EPHEMERAL_DOMAIN = new TextEncoder().encode("utxopia:deposit-ephemeral:v1");
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The delegable half of deposit recovery.
|
|
530
|
+
*
|
|
531
|
+
* Deposit addresses are derived from this node, so whoever holds it can rebuild
|
|
532
|
+
* every address the owner was ever handed, and with it every tapleaf and control
|
|
533
|
+
* block needed to spend those coins. It grants no spend authority over notes:
|
|
534
|
+
* that needs the spending key and the nullifying key, neither of which is
|
|
535
|
+
* derivable from here.
|
|
536
|
+
*
|
|
537
|
+
* Derived from the viewing key rather than the master seed on purpose. It means
|
|
538
|
+
* an owner can hand a chosen party — another device, a custodian, the pool
|
|
539
|
+
* operator — the ability to recover their BTC without handing over the ability
|
|
540
|
+
* to spend their notes. Same split Fluidkey uses for its ephemeral key nodes.
|
|
541
|
+
*/
|
|
542
|
+
export function depositViewingNode(viewingPrivKey: Uint8Array): Uint8Array {
|
|
543
|
+
if (viewingPrivKey.length === 0) {
|
|
544
|
+
throw new Error("viewingPrivKey must not be empty");
|
|
545
|
+
}
|
|
546
|
+
const material = new Uint8Array(DEPOSIT_VIEWING_NODE_DOMAIN.length + viewingPrivKey.length);
|
|
547
|
+
material.set(DEPOSIT_VIEWING_NODE_DOMAIN, 0);
|
|
548
|
+
material.set(viewingPrivKey, DEPOSIT_VIEWING_NODE_DOMAIN.length);
|
|
549
|
+
return sha256(material);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const OUTGOING_VIEWING_NODE_DOMAIN = new TextEncoder().encode(
|
|
553
|
+
"utxopia:outgoing-viewing-node:v1",
|
|
554
|
+
);
|
|
555
|
+
const OUTGOING_EPHEMERAL_DOMAIN = new TextEncoder().encode("utxopia:outgoing-ephemeral:v1");
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The delegable half of *outgoing* history.
|
|
559
|
+
*
|
|
560
|
+
* An announcement is encrypted to the recipient's viewing key, and the sender
|
|
561
|
+
* discards the ephemeral private key — so today a sender cannot rediscover what
|
|
562
|
+
* they paid out. Their history exists only in local storage. Indexing the
|
|
563
|
+
* ephemeral key off this node makes every outgoing payment recomputable from
|
|
564
|
+
* keys alone. Zcash calls the equivalent an outgoing viewing key.
|
|
565
|
+
*
|
|
566
|
+
* Deliberately a different node from `depositViewingNode`. The two authorise
|
|
567
|
+
* different things, and one should not smuggle in the other:
|
|
568
|
+
*
|
|
569
|
+
* - deposit node → "you can recover my BTC"
|
|
570
|
+
* - outgoing node → "you can see who I paid"
|
|
571
|
+
*/
|
|
572
|
+
export function outgoingViewingNode(viewingPrivKey: Uint8Array): Uint8Array {
|
|
573
|
+
if (viewingPrivKey.length === 0) {
|
|
574
|
+
throw new Error("viewingPrivKey must not be empty");
|
|
575
|
+
}
|
|
576
|
+
const material = new Uint8Array(OUTGOING_VIEWING_NODE_DOMAIN.length + viewingPrivKey.length);
|
|
577
|
+
material.set(OUTGOING_VIEWING_NODE_DOMAIN, 0);
|
|
578
|
+
material.set(viewingPrivKey, OUTGOING_VIEWING_NODE_DOMAIN.length);
|
|
579
|
+
return sha256(material);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Which outgoing payment an ephemeral key belongs to. */
|
|
583
|
+
export interface OutgoingRecoveryMaterial {
|
|
584
|
+
/** From `outgoingViewingNode(viewingPrivKey)`. */
|
|
585
|
+
outgoingNode: Uint8Array;
|
|
586
|
+
/** Monotonic per-sender counter. One payment per index. */
|
|
587
|
+
sendIndex: number;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** `sha256(domain || outgoingNode || sendIndex)` → Ed25519 ephemeral keypair. */
|
|
591
|
+
export function outgoingEphemeralKeyPair(outgoing: OutgoingRecoveryMaterial): {
|
|
592
|
+
privKey: Uint8Array;
|
|
593
|
+
pubKey: Uint8Array;
|
|
594
|
+
} {
|
|
595
|
+
const { outgoingNode, sendIndex } = outgoing;
|
|
596
|
+
if (outgoingNode.length === 0) {
|
|
597
|
+
throw new Error("outgoingNode must not be empty");
|
|
598
|
+
}
|
|
599
|
+
if (!Number.isInteger(sendIndex) || sendIndex < 0) {
|
|
600
|
+
throw new Error(`invalid sendIndex: ${sendIndex}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const material = new Uint8Array(OUTGOING_EPHEMERAL_DOMAIN.length + outgoingNode.length + 4);
|
|
604
|
+
material.set(OUTGOING_EPHEMERAL_DOMAIN, 0);
|
|
605
|
+
material.set(outgoingNode, OUTGOING_EPHEMERAL_DOMAIN.length);
|
|
606
|
+
new DataView(material.buffer).setUint32(
|
|
607
|
+
OUTGOING_EPHEMERAL_DOMAIN.length + outgoingNode.length,
|
|
608
|
+
sendIndex,
|
|
609
|
+
true,
|
|
610
|
+
);
|
|
611
|
+
return ed25519KeyPairFromMaterial(material);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Recover the next unused send index by walking 0 upward against the ephemeral
|
|
616
|
+
* pubkeys already on chain.
|
|
617
|
+
*
|
|
618
|
+
* An index is "used" when some announcement carries the ephemeral pubkey it
|
|
619
|
+
* derives. Scanning stops after `gapLimit` consecutive misses, because a
|
|
620
|
+
* derived-but-never-broadcast payment leaves a hole — an abandoned or failed
|
|
621
|
+
* transaction — and stopping at the first hole would hand back an index that is
|
|
622
|
+
* already spoken for.
|
|
623
|
+
*
|
|
624
|
+
* This is a FLOOR, not the live counter. A payment broadcast but not yet indexed
|
|
625
|
+
* is invisible here, so a sender that keeps local state must take
|
|
626
|
+
* `max(localCounter, findNextSendIndex(...))`. Reusing an index re-derives the
|
|
627
|
+
* same ephemeral key, and to the same recipient that means the same note
|
|
628
|
+
* commitment twice.
|
|
629
|
+
*/
|
|
630
|
+
export function findNextSendIndex(
|
|
631
|
+
outgoingNode: Uint8Array,
|
|
632
|
+
seenEphemeralPubs: Iterable<Uint8Array>,
|
|
633
|
+
gapLimit = 20,
|
|
634
|
+
): number {
|
|
635
|
+
const seen = new Set<string>();
|
|
636
|
+
for (const pub of seenEphemeralPubs) seen.add(bytesToHex(pub));
|
|
637
|
+
|
|
638
|
+
let highestUsed = -1;
|
|
639
|
+
let misses = 0;
|
|
640
|
+
for (let index = 0; misses <= gapLimit; index++) {
|
|
641
|
+
if (seen.has(bytesToHex(outgoingEphemeralKeyPair({ outgoingNode, sendIndex: index }).pubKey))) {
|
|
642
|
+
highestUsed = index;
|
|
643
|
+
misses = 0;
|
|
644
|
+
} else {
|
|
645
|
+
misses++;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return highestUsed + 1;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* What makes a deposit address reconstructable.
|
|
653
|
+
*
|
|
654
|
+
* The ephemeral key is NOT random. A deposit address commits to it via the
|
|
655
|
+
* tapleaf, and the key path is a NUMS point, so an address whose ephemeral key
|
|
656
|
+
* is lost is an address nobody — not the owner, not the pool — can ever spend.
|
|
657
|
+
* Indexing it off the viewing node makes that node a complete backup: walk
|
|
658
|
+
* `depositIndex` upward and every address comes back.
|
|
659
|
+
*/
|
|
660
|
+
export interface DepositRecoveryMaterial {
|
|
661
|
+
/** From `depositViewingNode(viewingPrivKey)`. */
|
|
662
|
+
viewingNode: Uint8Array;
|
|
663
|
+
/** Monotonic per-owner counter. One address per index. */
|
|
664
|
+
depositIndex: number;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** `sha256(domain || viewingNode || depositIndex)` → Ed25519 ephemeral keypair. */
|
|
668
|
+
export function depositEphemeralKeyPair(recovery: DepositRecoveryMaterial): {
|
|
669
|
+
privKey: Uint8Array;
|
|
670
|
+
pubKey: Uint8Array;
|
|
671
|
+
} {
|
|
672
|
+
const { viewingNode, depositIndex } = recovery;
|
|
673
|
+
if (viewingNode.length === 0) {
|
|
674
|
+
throw new Error("deposit viewingNode must not be empty");
|
|
675
|
+
}
|
|
676
|
+
if (!Number.isInteger(depositIndex) || depositIndex < 0) {
|
|
677
|
+
throw new Error(`invalid depositIndex: ${depositIndex}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const material = new Uint8Array(DEPOSIT_EPHEMERAL_DOMAIN.length + viewingNode.length + 4);
|
|
681
|
+
material.set(DEPOSIT_EPHEMERAL_DOMAIN, 0);
|
|
682
|
+
material.set(viewingNode, DEPOSIT_EPHEMERAL_DOMAIN.length);
|
|
683
|
+
new DataView(material.buffer).setUint32(
|
|
684
|
+
DEPOSIT_EPHEMERAL_DOMAIN.length + viewingNode.length,
|
|
685
|
+
depositIndex,
|
|
686
|
+
true,
|
|
687
|
+
);
|
|
688
|
+
return ed25519KeyPairFromMaterial(material);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** A deposit whose address alone binds the note keys — no OP_RETURN. */
|
|
692
|
+
export interface TweakDepositResult {
|
|
693
|
+
/** Taproot address to send BTC to */
|
|
694
|
+
btcAddress: string;
|
|
695
|
+
/** 32-byte x-only output key for the deposit P2TR output */
|
|
696
|
+
depositOutputKey: Uint8Array;
|
|
697
|
+
/** 32-byte note public key */
|
|
698
|
+
npk: Uint8Array;
|
|
699
|
+
/** 32-byte Ed25519 ephemeral public key */
|
|
700
|
+
ephemeralPub: Uint8Array;
|
|
701
|
+
/** sha256(npk || ephemeralPub) — the commitment carried in the tapleaf */
|
|
702
|
+
tweakCommitment: Uint8Array;
|
|
703
|
+
/** The tapleaf: `<commitment> OP_DROP <ika_xonly> OP_CHECKSIG` */
|
|
704
|
+
leafScript: Uint8Array;
|
|
705
|
+
/** Its BIP-341 tapleaf hash, which is also the merkle root (single leaf) */
|
|
706
|
+
leafHash: Uint8Array;
|
|
707
|
+
/** Script-path witness is `[signature, leafScript, controlBlock]` */
|
|
708
|
+
controlBlock: Uint8Array;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Create a deposit for the OP_RETURN-free flow (`verify_deposit`, disc 25).
|
|
713
|
+
*
|
|
714
|
+
* The transaction carries nothing but a payment, so anything that can send to a
|
|
715
|
+
* P2TR address can fund it — a hardware wallet, an exchange withdrawal, a faucet.
|
|
716
|
+
* The note keys are recovered from instruction data at completion time and proven
|
|
717
|
+
* against this address's tapleaf, so substituting either key derives a different
|
|
718
|
+
* leaf, and so a different address that the funding transaction never paid.
|
|
719
|
+
*
|
|
720
|
+
* The address is spendable only by `vaultXOnlyPubkey` via the script path; its
|
|
721
|
+
* key path is a NUMS point. That keeps the deposit under Ika custody from the
|
|
722
|
+
* moment it confirms, and it is also the only shape Ika can sign for — its MPC
|
|
723
|
+
* cannot produce a signature for a tweaked key.
|
|
724
|
+
*
|
|
725
|
+
* `recovery` is not optional on purpose. The address commits to the ephemeral
|
|
726
|
+
* key and the key path is unspendable, so a random ephemeral key that is later
|
|
727
|
+
* lost burns the coins outright. Indexing it off the viewing node means that
|
|
728
|
+
* node is the backup — and it can be delegated without granting spend authority.
|
|
729
|
+
*
|
|
730
|
+
* Register the address with the tracker BEFORE any coins are sent — a deposit
|
|
731
|
+
* with no OP_RETURN is invisible to block scanning, so an unregistered address
|
|
732
|
+
* is one nobody is watching.
|
|
733
|
+
*/
|
|
734
|
+
export async function createTweakDeposit(
|
|
735
|
+
recipientMeta: StealthMetaAddress,
|
|
736
|
+
vaultXOnlyPubkey: Uint8Array,
|
|
737
|
+
recovery: DepositRecoveryMaterial,
|
|
738
|
+
network: "mainnet" | "testnet" | "regtest" = "testnet",
|
|
739
|
+
): Promise<TweakDepositResult> {
|
|
740
|
+
if (vaultXOnlyPubkey.length !== 32) {
|
|
741
|
+
throw new Error("vaultXOnlyPubkey must be 32 bytes");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
|
|
745
|
+
const ephemeral = depositEphemeralKeyPair(recovery);
|
|
746
|
+
const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
|
|
747
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
748
|
+
const recipientMPK = bytesToBigint(recipientMeta.mpk);
|
|
749
|
+
const npk = bigintToBytes(computeNPKSync(recipientMPK, stealthScalar));
|
|
750
|
+
const ephemeralPub = new Uint8Array(ephemeral.pubKey);
|
|
751
|
+
|
|
752
|
+
const { depositTweakCommitment, deriveDepositAddress } = await import("./taproot");
|
|
753
|
+
const tweakCommitment = depositTweakCommitment(npk, ephemeralPub);
|
|
754
|
+
const { address, outputKey, leafScript, leafHash, controlBlock } = deriveDepositAddress(
|
|
755
|
+
tweakCommitment,
|
|
756
|
+
vaultXOnlyPubkey,
|
|
757
|
+
network,
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
btcAddress: address,
|
|
762
|
+
depositOutputKey: outputKey,
|
|
763
|
+
npk,
|
|
764
|
+
ephemeralPub,
|
|
765
|
+
tweakCommitment,
|
|
766
|
+
leafScript,
|
|
767
|
+
leafHash,
|
|
768
|
+
controlBlock,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Create a non-interactive deposit using the current SDK config.
|
|
774
|
+
*
|
|
775
|
+
* Direct-vault/Ika deposit helper.
|
|
776
|
+
*
|
|
777
|
+
* Deposits go to the raw Ika x-only P2TR vault address. Recipient binding
|
|
778
|
+
* stays per-deposit in OP_RETURN(header || poolTag || ephemeralPub || npk), and the destination chain
|
|
779
|
+
* credits the note by SPV-verifying that deposit transaction directly. Legacy sweep-mode
|
|
780
|
+
* address derivation is intentionally not selected from config anymore.
|
|
781
|
+
*/
|
|
782
|
+
export async function createDepositFromConfig(
|
|
783
|
+
recipientMeta: StealthMetaAddress,
|
|
784
|
+
network: "mainnet" | "testnet" | "regtest" = "testnet",
|
|
785
|
+
opReturnContext?: DepositOpReturnContext,
|
|
786
|
+
): Promise<NonInteractiveDepositResult> {
|
|
787
|
+
const config = getConfig();
|
|
788
|
+
const ikaKey = pickIkaCustodyKey(config);
|
|
789
|
+
if (!ikaKey) {
|
|
790
|
+
throw new Error("Ika direct-vault deposits require ikaDwalletXOnlyPubkey in config");
|
|
791
|
+
}
|
|
792
|
+
if (config.depositMode && !isDirectVaultDepositMode(config.depositMode)) {
|
|
793
|
+
throw new Error(`Unsupported depositMode "${config.depositMode}"; only Ika direct-vault deposits are supported`);
|
|
794
|
+
}
|
|
795
|
+
return createDirectVaultDeposit(recipientMeta, ikaKey, network, opReturnContext);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export function isDirectVaultDepositMode(mode?: string): boolean {
|
|
799
|
+
return mode === "direct" || mode === "direct_vault" || mode === "ika_direct";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Choose the Taproot internal key for deposit-address derivation.
|
|
804
|
+
* The Ika dWallet x-only pubkey is the sole custody key; throws if unset.
|
|
805
|
+
* Exported for unit tests; non-test callers should use `createDepositFromConfig`.
|
|
806
|
+
*/
|
|
807
|
+
export function pickCustodyInternalKey(config: {
|
|
808
|
+
ikaDwalletXOnlyPubkey?: string;
|
|
809
|
+
}): Uint8Array {
|
|
810
|
+
const ikaKey = pickIkaCustodyKey(config);
|
|
811
|
+
if (!ikaKey) {
|
|
812
|
+
throw new Error(
|
|
813
|
+
"PoolConfig.ika_dwallet_xonly_pubkey is required; pool custody key is not configured",
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
return ikaKey;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function pickIkaCustodyKey(config: {
|
|
820
|
+
ikaDwalletXOnlyPubkey?: string;
|
|
821
|
+
}): Uint8Array | null {
|
|
822
|
+
const ikaHex = config.ikaDwalletXOnlyPubkey ?? "";
|
|
823
|
+
if (ikaHex && /[1-9a-f]/i.test(ikaHex)) {
|
|
824
|
+
return hexToBytes(ikaHex);
|
|
825
|
+
}
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ========== Recipient Scanning (Viewing Key Only) ==========
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Scan announcements using viewing key only
|
|
833
|
+
*/
|
|
834
|
+
export async function scanAnnouncements(
|
|
835
|
+
source: WalletSignerAdapter | UTXOpiaKeys,
|
|
836
|
+
announcements: {
|
|
837
|
+
ephemeralPub: Uint8Array;
|
|
838
|
+
encryptedAmount: Uint8Array;
|
|
839
|
+
commitment: Uint8Array;
|
|
840
|
+
leafIndex: number;
|
|
841
|
+
}[],
|
|
842
|
+
tokenId: bigint,
|
|
843
|
+
): Promise<ScannedNote[]> {
|
|
844
|
+
const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
|
|
845
|
+
|
|
846
|
+
const found: ScannedNote[] = [];
|
|
847
|
+
const MAX_SATS = 21_000_000n * 100_000_000n;
|
|
848
|
+
|
|
849
|
+
// Compute MPK for this key set
|
|
850
|
+
const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
|
|
851
|
+
|
|
852
|
+
for (const ann of announcements) {
|
|
853
|
+
try {
|
|
854
|
+
// X25519 ECDH with viewing key
|
|
855
|
+
const sharedSecret = x25519Ecdh(keys.viewingPrivKey, ann.ephemeralPub);
|
|
856
|
+
|
|
857
|
+
// Decrypt amount
|
|
858
|
+
const amount = decryptAmount(ann.encryptedAmount, sharedSecret);
|
|
859
|
+
|
|
860
|
+
if (amount <= 0n || amount > MAX_SATS) {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Derive stealth public key (still needed for spending)
|
|
865
|
+
const stealthPub = deriveStealthPubKey(keys.spendingPubKey, sharedSecret);
|
|
866
|
+
|
|
867
|
+
// Derive stealth scalar as random for NPK
|
|
868
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
869
|
+
|
|
870
|
+
// Compute expected NPK and commitment (JoinSplit format)
|
|
871
|
+
const npk = computeNPKSync(mpk, stealthScalar);
|
|
872
|
+
const expectedCommitment = computeJoinSplitCommitmentSync(npk, tokenId, amount);
|
|
873
|
+
const actualCommitment = bytesToBigint(ann.commitment);
|
|
874
|
+
|
|
875
|
+
if (expectedCommitment !== actualCommitment) {
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
found.push({
|
|
880
|
+
amount,
|
|
881
|
+
ephemeralPub: ann.ephemeralPub,
|
|
882
|
+
stealthPub,
|
|
883
|
+
leafIndex: ann.leafIndex,
|
|
884
|
+
commitment: ann.commitment,
|
|
885
|
+
});
|
|
886
|
+
} catch (error) {
|
|
887
|
+
// Re-throw programming errors; only skip data/crypto mismatches
|
|
888
|
+
if (error instanceof TypeError || error instanceof RangeError) {
|
|
889
|
+
throw error;
|
|
890
|
+
}
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return found;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ========== View-Only Scanning ==========
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* View-only keys for scanning without spending capability
|
|
902
|
+
*/
|
|
903
|
+
export interface ViewOnlyKeys {
|
|
904
|
+
/** Ed25519 viewing private key (32 bytes) */
|
|
905
|
+
viewingPrivKey: Uint8Array;
|
|
906
|
+
/** Baby Jubjub spending public key */
|
|
907
|
+
spendingPubKey: BabyJubPoint;
|
|
908
|
+
/** Nullifying key (needed for MPK computation in JoinSplit scanning) */
|
|
909
|
+
nullifyingKey: bigint;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Scanned note from view-only scanning
|
|
914
|
+
*/
|
|
915
|
+
export interface ViewOnlyScannedNote {
|
|
916
|
+
amount: bigint;
|
|
917
|
+
leafIndex: number;
|
|
918
|
+
commitment: Uint8Array;
|
|
919
|
+
ephemeralPub: Uint8Array;
|
|
920
|
+
/** Unix timestamp (seconds) from on-chain block_time, 0 if unavailable */
|
|
921
|
+
blockTime?: number;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Scan announcements with VIEW-ONLY keys against SEVERAL token ids in one pass.
|
|
926
|
+
* Same one-ECDH-per-announcement economics as {@link scanUnifiedNotesMulti}.
|
|
927
|
+
*/
|
|
928
|
+
export async function scanAnnouncementsViewOnlyMulti(
|
|
929
|
+
viewOnlyKeys: ViewOnlyKeys,
|
|
930
|
+
announcements: {
|
|
931
|
+
announcementType: number;
|
|
932
|
+
ephemeralPub: Uint8Array;
|
|
933
|
+
encryptedAmount: Uint8Array;
|
|
934
|
+
commitment: Uint8Array;
|
|
935
|
+
leafIndex: number;
|
|
936
|
+
blockTime?: number;
|
|
937
|
+
}[],
|
|
938
|
+
tokenIds: bigint[],
|
|
939
|
+
): Promise<Array<ViewOnlyScannedNote & { tokenId: bigint }>> {
|
|
940
|
+
return matchAnnouncements(viewOnlyKeys, announcements, tokenIds).map((match) => ({
|
|
941
|
+
amount: match.amount,
|
|
942
|
+
leafIndex: match.announcement.leafIndex,
|
|
943
|
+
commitment: match.commitment,
|
|
944
|
+
ephemeralPub: match.announcement.ephemeralPub,
|
|
945
|
+
blockTime: match.announcement.blockTime ?? 0,
|
|
946
|
+
tokenId: match.tokenId,
|
|
947
|
+
}));
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Scan announcements with VIEW-ONLY keys.
|
|
952
|
+
* Latest announcement rows must carry an explicit type.
|
|
953
|
+
*/
|
|
954
|
+
export async function scanAnnouncementsViewOnly(
|
|
955
|
+
viewOnlyKeys: ViewOnlyKeys,
|
|
956
|
+
announcements: {
|
|
957
|
+
announcementType: number;
|
|
958
|
+
ephemeralPub: Uint8Array;
|
|
959
|
+
encryptedAmount: Uint8Array;
|
|
960
|
+
commitment: Uint8Array;
|
|
961
|
+
leafIndex: number;
|
|
962
|
+
blockTime?: number;
|
|
963
|
+
}[],
|
|
964
|
+
tokenId: bigint,
|
|
965
|
+
): Promise<ViewOnlyScannedNote[]> {
|
|
966
|
+
return scanAnnouncementsViewOnlyMulti(viewOnlyKeys, announcements, [tokenId]);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Export view-only keys from full UTXOpiaKeys
|
|
971
|
+
*/
|
|
972
|
+
export function exportViewOnlyKeys(keys: UTXOpiaKeys): ViewOnlyKeys {
|
|
973
|
+
return {
|
|
974
|
+
viewingPrivKey: keys.viewingPrivKey,
|
|
975
|
+
spendingPubKey: keys.spendingPubKey,
|
|
976
|
+
nullifyingKey: keys.nullifyingKey,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Encode view-only keys as a hex string for sharing
|
|
982
|
+
* Format: viewingPrivKey(32) + compressedSpendingPub(32) + nullifyingKey(32) = 96 bytes
|
|
983
|
+
*/
|
|
984
|
+
export function encodeViewOnlyKeys(keys: ViewOnlyKeys): string {
|
|
985
|
+
const compressed = babyJubCompress(keys.spendingPubKey);
|
|
986
|
+
const nullBytes = bigintToBytes(keys.nullifyingKey);
|
|
987
|
+
const combined = new Uint8Array(96);
|
|
988
|
+
combined.set(keys.viewingPrivKey, 0);
|
|
989
|
+
combined.set(compressed, 32);
|
|
990
|
+
combined.set(nullBytes, 64);
|
|
991
|
+
return bytesToHex(combined);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Decode view-only keys from a hex string
|
|
996
|
+
*/
|
|
997
|
+
export function decodeViewOnlyKeys(encoded: string): ViewOnlyKeys {
|
|
998
|
+
const bytes = hexToBytes(encoded);
|
|
999
|
+
if (bytes.length !== 96) {
|
|
1000
|
+
throw new Error("Invalid view-only key length (expected 96 bytes)");
|
|
1001
|
+
}
|
|
1002
|
+
const viewingPrivKey = bytes.slice(0, 32);
|
|
1003
|
+
const compressed = bytes.slice(32, 64);
|
|
1004
|
+
const spendingPubKey = babyJubDecompress(compressed);
|
|
1005
|
+
const nullifyingKey = bytesToBigint(bytes.slice(64, 96));
|
|
1006
|
+
return { viewingPrivKey, spendingPubKey, nullifyingKey };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// ========== Claim Preparation (Spending Key Required) ==========
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Prepare claim inputs for ZK proof generation
|
|
1013
|
+
*/
|
|
1014
|
+
export async function prepareClaimInputs(
|
|
1015
|
+
source: WalletSignerAdapter | UTXOpiaKeys,
|
|
1016
|
+
note: ScannedNote,
|
|
1017
|
+
merkleProof: {
|
|
1018
|
+
root: bigint;
|
|
1019
|
+
pathElements: bigint[];
|
|
1020
|
+
pathIndices: number[];
|
|
1021
|
+
}
|
|
1022
|
+
): Promise<ClaimInputs> {
|
|
1023
|
+
const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
|
|
1024
|
+
|
|
1025
|
+
// X25519 ECDH to recover shared secret
|
|
1026
|
+
const sharedSecret = x25519Ecdh(keys.viewingPrivKey, note.ephemeralPub);
|
|
1027
|
+
|
|
1028
|
+
// Derive stealth private key (Baby Jubjub scalar addition)
|
|
1029
|
+
const stealthPrivKey = deriveStealthPrivKey(keys.spendingPrivKey, sharedSecret);
|
|
1030
|
+
|
|
1031
|
+
// Verify stealth public key matches
|
|
1032
|
+
const expectedStealthPub = babyJubMul(stealthPrivKey, BABYJUB_BASE8);
|
|
1033
|
+
if (expectedStealthPub.x !== note.stealthPub.x || expectedStealthPub.y !== note.stealthPub.y) {
|
|
1034
|
+
throw new Error(
|
|
1035
|
+
"Stealth key mismatch - this note may not belong to you or the announcement is invalid"
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Derive the random value (stealth scalar) for NPK
|
|
1040
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
1041
|
+
|
|
1042
|
+
// Compute MPK and NPK
|
|
1043
|
+
const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
|
|
1044
|
+
const npk = computeNPKSync(mpk, stealthScalar);
|
|
1045
|
+
|
|
1046
|
+
// Compute JoinSplit nullifier
|
|
1047
|
+
const nullifier = computeJoinSplitNullifierSync(keys.nullifyingKey, BigInt(note.leafIndex));
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
stealthPrivKey,
|
|
1051
|
+
nullifyingKey: keys.nullifyingKey,
|
|
1052
|
+
amount: note.amount,
|
|
1053
|
+
leafIndex: note.leafIndex,
|
|
1054
|
+
merklePath: merkleProof.pathElements,
|
|
1055
|
+
merkleIndices: merkleProof.pathIndices,
|
|
1056
|
+
merkleRoot: merkleProof.root,
|
|
1057
|
+
nullifier,
|
|
1058
|
+
npk,
|
|
1059
|
+
random: stealthScalar,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ========== Unified Note Scanning ==========
|
|
1064
|
+
|
|
1065
|
+
/** Announcement shape the scanners need. Wider than OnChainStealthAnnouncement
|
|
1066
|
+
* so view-only callers can pass their own rows. */
|
|
1067
|
+
interface ScannableAnnouncement {
|
|
1068
|
+
announcementType: number;
|
|
1069
|
+
ephemeralPub: Uint8Array;
|
|
1070
|
+
encryptedAmount: Uint8Array;
|
|
1071
|
+
commitment: Uint8Array;
|
|
1072
|
+
leafIndex: number;
|
|
1073
|
+
blockTime?: number;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** What a scan recovers before it is shaped into a note. */
|
|
1077
|
+
interface AnnouncementMatch {
|
|
1078
|
+
announcement: ScannableAnnouncement;
|
|
1079
|
+
amount: bigint;
|
|
1080
|
+
tokenId: bigint;
|
|
1081
|
+
commitment: Uint8Array;
|
|
1082
|
+
sharedSecret: Uint8Array;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const MAX_SATS = 21_000_000n * 100_000_000n;
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Trial-decrypt each announcement ONCE and test the result against every token
|
|
1089
|
+
* id, instead of redoing the whole derivation per token.
|
|
1090
|
+
*
|
|
1091
|
+
* Everything up to the commitment — the x25519 ECDH, the amount, the stealth
|
|
1092
|
+
* scalar, the NPK — is token-independent; only the closing
|
|
1093
|
+
* Poseidon(npk, tokenId, amount) comparison is not. Scanning T tokens the naive
|
|
1094
|
+
* way therefore paid T ECDHs per announcement to answer one question, and the
|
|
1095
|
+
* ECDH is the expensive half.
|
|
1096
|
+
*
|
|
1097
|
+
* A commitment binds exactly one token id, so the first match wins and the
|
|
1098
|
+
* remaining ids are skipped.
|
|
1099
|
+
*/
|
|
1100
|
+
function matchAnnouncements(
|
|
1101
|
+
viewKeys: ViewOnlyKeys,
|
|
1102
|
+
announcements: ScannableAnnouncement[],
|
|
1103
|
+
tokenIds: bigint[],
|
|
1104
|
+
): AnnouncementMatch[] {
|
|
1105
|
+
if (tokenIds.length === 0) return [];
|
|
1106
|
+
|
|
1107
|
+
const matches: AnnouncementMatch[] = [];
|
|
1108
|
+
const mpk = computeMPKSync(
|
|
1109
|
+
viewKeys.spendingPubKey.x,
|
|
1110
|
+
viewKeys.spendingPubKey.y,
|
|
1111
|
+
viewKeys.nullifyingKey,
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
for (const ann of announcements) {
|
|
1115
|
+
try {
|
|
1116
|
+
if (!isKnownAnnouncementType(ann.announcementType)) {
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// X25519 ECDH with viewing key — once per announcement, not per token
|
|
1121
|
+
const sharedSecret = x25519Ecdh(viewKeys.viewingPrivKey, ann.ephemeralPub);
|
|
1122
|
+
|
|
1123
|
+
// Get amount based on type
|
|
1124
|
+
let amount: bigint;
|
|
1125
|
+
if (ann.announcementType === ANNOUNCEMENT_TYPE_DEPOSIT) {
|
|
1126
|
+
// Plaintext u64 LE
|
|
1127
|
+
const view = new DataView(ann.encryptedAmount.buffer, ann.encryptedAmount.byteOffset, 8);
|
|
1128
|
+
amount = view.getBigUint64(0, true);
|
|
1129
|
+
} else {
|
|
1130
|
+
// XOR-encrypted
|
|
1131
|
+
amount = decryptAmount(ann.encryptedAmount, sharedSecret);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (amount <= 0n || amount > MAX_SATS) {
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Derive stealth scalar and expected NPK (computed locally)
|
|
1139
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
1140
|
+
const npk = computeNPKSync(mpk, stealthScalar);
|
|
1141
|
+
const onChain = bytesToBigint(ann.commitment);
|
|
1142
|
+
|
|
1143
|
+
for (const tokenId of tokenIds) {
|
|
1144
|
+
// Verify the recomputed commitment against the on-chain one for BOTH
|
|
1145
|
+
// deposits and transfers — a foreign transfer whose XOR-decrypted amount
|
|
1146
|
+
// lands in range would otherwise become a phantom note.
|
|
1147
|
+
const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amount);
|
|
1148
|
+
if (commitmentBigint !== onChain) continue;
|
|
1149
|
+
|
|
1150
|
+
matches.push({
|
|
1151
|
+
announcement: ann,
|
|
1152
|
+
amount,
|
|
1153
|
+
tokenId,
|
|
1154
|
+
// Use on-chain commitment bytes for transfers (preserves exact on-chain value)
|
|
1155
|
+
commitment: ann.announcementType === ANNOUNCEMENT_TYPE_DEPOSIT
|
|
1156
|
+
? bigintToBytes(commitmentBigint)
|
|
1157
|
+
: new Uint8Array(ann.commitment),
|
|
1158
|
+
sharedSecret,
|
|
1159
|
+
});
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (error instanceof TypeError || error instanceof RangeError) {
|
|
1164
|
+
throw error;
|
|
1165
|
+
}
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return matches;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** A note found by a multi-token scan, tagged with the token id it matched. */
|
|
1174
|
+
export type MultiScannedNote = ScannedNote & { tokenId: bigint };
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Scan unified StealthAnnouncement notes (both deposits and transfers) against
|
|
1178
|
+
* SEVERAL token ids in one pass.
|
|
1179
|
+
*
|
|
1180
|
+
* Prefer this over calling {@link scanUnifiedNotes} once per token: the cost is
|
|
1181
|
+
* one ECDH per announcement either way, plus one Poseidon per token id tried.
|
|
1182
|
+
*/
|
|
1183
|
+
export async function scanUnifiedNotesMulti(
|
|
1184
|
+
source: WalletSignerAdapter | UTXOpiaKeys,
|
|
1185
|
+
announcements: OnChainStealthAnnouncement[],
|
|
1186
|
+
tokenIds: bigint[],
|
|
1187
|
+
): Promise<MultiScannedNote[]> {
|
|
1188
|
+
const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
|
|
1189
|
+
|
|
1190
|
+
return matchAnnouncements(exportViewOnlyKeys(keys), announcements, tokenIds).map((match) => ({
|
|
1191
|
+
amount: match.amount,
|
|
1192
|
+
ephemeralPub: match.announcement.ephemeralPub,
|
|
1193
|
+
// Stealth public key, for spending
|
|
1194
|
+
stealthPub: deriveStealthPubKey(keys.spendingPubKey, match.sharedSecret),
|
|
1195
|
+
leafIndex: match.announcement.leafIndex,
|
|
1196
|
+
commitment: match.commitment,
|
|
1197
|
+
blockTime: match.announcement.blockTime ?? 0,
|
|
1198
|
+
tokenId: match.tokenId,
|
|
1199
|
+
}));
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Scan unified StealthAnnouncement notes (both deposits and transfers).
|
|
1204
|
+
*
|
|
1205
|
+
* For each announcement:
|
|
1206
|
+
* - type=0 (deposit): amount is plaintext u64 LE in amount_bytes
|
|
1207
|
+
* - type=1 (transfer): amount is XOR-encrypted in amount_bytes
|
|
1208
|
+
*
|
|
1209
|
+
* Commitment is computed locally: Poseidon(npk, tokenId, amount) and compared
|
|
1210
|
+
* against the on-chain one, which is what proves the note is ours.
|
|
1211
|
+
*/
|
|
1212
|
+
export async function scanUnifiedNotes(
|
|
1213
|
+
source: WalletSignerAdapter | UTXOpiaKeys,
|
|
1214
|
+
announcements: OnChainStealthAnnouncement[],
|
|
1215
|
+
tokenId: bigint,
|
|
1216
|
+
): Promise<ScannedNote[]> {
|
|
1217
|
+
return scanUnifiedNotesMulti(source, announcements, [tokenId]);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ========== Connection Adapter ==========
|
|
1221
|
+
|
|
1222
|
+
import type { Address } from "@solana/kit";
|
|
1223
|
+
|
|
1224
|
+
export interface ConnectionAdapter {
|
|
1225
|
+
getAccountInfo: (
|
|
1226
|
+
pubkey: Address
|
|
1227
|
+
) => Promise<{ data: Uint8Array } | null>;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ========== Stealth Output Creation ==========
|
|
1231
|
+
|
|
1232
|
+
export interface StealthOutputData {
|
|
1233
|
+
/** Ed25519 ephemeral public key (32 bytes) */
|
|
1234
|
+
ephemeralPub: Uint8Array;
|
|
1235
|
+
/** XOR encrypted amount (8 bytes) */
|
|
1236
|
+
encryptedAmount: Uint8Array;
|
|
1237
|
+
/** Commitment = Poseidon(stealthPub.x, amount) */
|
|
1238
|
+
commitment: Uint8Array;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Circuit-ready stealth output data
|
|
1243
|
+
*/
|
|
1244
|
+
export interface CircuitStealthOutput {
|
|
1245
|
+
/** Ephemeral pubkey (32 bytes as bigint) */
|
|
1246
|
+
ephemeralPubX: bigint;
|
|
1247
|
+
/** Packed: bits 0-63 = encrypted amount, bit 64 = reserved (0 for Ed25519) */
|
|
1248
|
+
encryptedAmountWithSign: bigint;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Pack encrypted amount (no y_sign needed for Ed25519 — 32-byte keys, no prefix)
|
|
1253
|
+
*
|
|
1254
|
+
* Layout: bits 0-63 = encrypted amount (little-endian), bit 64 = 0 (reserved)
|
|
1255
|
+
*/
|
|
1256
|
+
export function packEncryptedAmountWithSign(encryptedAmount: Uint8Array, _ySign: boolean = false): bigint {
|
|
1257
|
+
if (encryptedAmount.length !== 8) {
|
|
1258
|
+
throw new Error("Encrypted amount must be 8 bytes");
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
let amount = 0n;
|
|
1262
|
+
for (let i = 7; i >= 0; i--) {
|
|
1263
|
+
amount = (amount << 8n) | BigInt(encryptedAmount[i]);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// For Ed25519, we don't have a y_sign prefix, but keep the bit for compatibility
|
|
1267
|
+
if (_ySign) {
|
|
1268
|
+
amount |= (1n << 64n);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
return amount;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Convert StealthOutputData to circuit-ready format
|
|
1276
|
+
*/
|
|
1277
|
+
export function packStealthOutputForCircuit(output: StealthOutputData): CircuitStealthOutput {
|
|
1278
|
+
// Ed25519 ephemeral pub is 32 bytes — interpret as big-endian bigint
|
|
1279
|
+
const ephemeralPubX = bytesToBigint(output.ephemeralPub);
|
|
1280
|
+
const encryptedAmountWithSign = packEncryptedAmountWithSign(output.encryptedAmount);
|
|
1281
|
+
|
|
1282
|
+
return {
|
|
1283
|
+
ephemeralPubX,
|
|
1284
|
+
encryptedAmountWithSign,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Unpack encrypted amount from packed Field element
|
|
1290
|
+
*/
|
|
1291
|
+
export function unpackEncryptedAmountWithSign(packed: bigint): { encryptedAmount: Uint8Array; ySign: boolean } {
|
|
1292
|
+
const ySign = (packed & (1n << 64n)) !== 0n;
|
|
1293
|
+
const amount = packed & ((1n << 64n) - 1n);
|
|
1294
|
+
|
|
1295
|
+
const encryptedAmount = new Uint8Array(8);
|
|
1296
|
+
let temp = amount;
|
|
1297
|
+
for (let i = 0; i < 8; i++) {
|
|
1298
|
+
encryptedAmount[i] = Number(temp & 0xffn);
|
|
1299
|
+
temp >>= 8n;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return { encryptedAmount, ySign };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Create stealth output data for a self-send (change output)
|
|
1307
|
+
*/
|
|
1308
|
+
export async function createStealthOutput(
|
|
1309
|
+
keys: UTXOpiaKeys,
|
|
1310
|
+
amountSats: bigint,
|
|
1311
|
+
tokenId: bigint,
|
|
1312
|
+
): Promise<StealthOutputData> {
|
|
1313
|
+
const ephemeral = ed25519GenerateKeyPair();
|
|
1314
|
+
const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
|
|
1315
|
+
|
|
1316
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
1317
|
+
const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
|
|
1318
|
+
const npk = computeNPKSync(mpk, stealthScalar);
|
|
1319
|
+
|
|
1320
|
+
const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
|
|
1321
|
+
const commitment = bigintToBytes(commitmentBigint);
|
|
1322
|
+
const encryptedAmount = encryptAmount(amountSats, sharedSecret);
|
|
1323
|
+
|
|
1324
|
+
return {
|
|
1325
|
+
ephemeralPub: new Uint8Array(ephemeral.pubKey),
|
|
1326
|
+
encryptedAmount,
|
|
1327
|
+
commitment,
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Create stealth output with npk for JoinSplit circuit input
|
|
1333
|
+
*/
|
|
1334
|
+
export async function createStealthOutputWithKeys(
|
|
1335
|
+
keys: UTXOpiaKeys,
|
|
1336
|
+
amountSats: bigint,
|
|
1337
|
+
tokenId: bigint,
|
|
1338
|
+
): Promise<StealthOutputWithKeys> {
|
|
1339
|
+
const ephemeral = ed25519GenerateKeyPair();
|
|
1340
|
+
const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
|
|
1341
|
+
|
|
1342
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
1343
|
+
const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
|
|
1344
|
+
const npk = computeNPKSync(mpk, stealthScalar);
|
|
1345
|
+
|
|
1346
|
+
const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
|
|
1347
|
+
const commitment = bigintToBytes(commitmentBigint);
|
|
1348
|
+
const encryptedAmount = encryptAmount(amountSats, sharedSecret);
|
|
1349
|
+
|
|
1350
|
+
return {
|
|
1351
|
+
ephemeralPub: new Uint8Array(ephemeral.pubKey),
|
|
1352
|
+
encryptedAmount,
|
|
1353
|
+
commitment,
|
|
1354
|
+
stealthPubKeyX: npk,
|
|
1355
|
+
npkBytes: bigintToBytes(npk),
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Create stealth output data with pre-computed commitment
|
|
1361
|
+
*/
|
|
1362
|
+
export async function createStealthOutputForCommitment(
|
|
1363
|
+
keys: UTXOpiaKeys,
|
|
1364
|
+
amountSats: bigint,
|
|
1365
|
+
existingCommitment: Uint8Array
|
|
1366
|
+
): Promise<StealthOutputData> {
|
|
1367
|
+
const ephemeral = ed25519GenerateKeyPair();
|
|
1368
|
+
const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
|
|
1369
|
+
|
|
1370
|
+
const encryptedAmount = encryptAmount(amountSats, sharedSecret);
|
|
1371
|
+
|
|
1372
|
+
return {
|
|
1373
|
+
ephemeralPub: new Uint8Array(ephemeral.pubKey),
|
|
1374
|
+
encryptedAmount,
|
|
1375
|
+
commitment: existingCommitment,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// ========== Nullifier Computation ==========
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Compute nullifier hash for a scanned note
|
|
1383
|
+
*/
|
|
1384
|
+
export function computeNullifierHashForNote(
|
|
1385
|
+
keys: UTXOpiaKeys,
|
|
1386
|
+
note: ScannedNote
|
|
1387
|
+
): Uint8Array {
|
|
1388
|
+
// In JoinSplit model, nullifier = Poseidon(nullifyingKey, leafIndex)
|
|
1389
|
+
// No extra hash layer — the nullifier IS the public output
|
|
1390
|
+
const nullifier = computeJoinSplitNullifierSync(keys.nullifyingKey, BigInt(note.leafIndex));
|
|
1391
|
+
return bigintToBytes(nullifier);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Compute nullifier hash for a note and return as raw bytes.
|
|
1396
|
+
* Convenience wrapper — avoids importing computeJoinSplitNullifierSync + bigintToBytes in consumers.
|
|
1397
|
+
*/
|
|
1398
|
+
export function computeNullifierBytes(nullifyingKey: bigint, leafIndex: number): Uint8Array {
|
|
1399
|
+
const nullifier = computeJoinSplitNullifierSync(nullifyingKey, BigInt(leafIndex));
|
|
1400
|
+
return bigintToBytes(nullifier);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ========== Announcement Parsing ==========
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Parse backend announcement rows (hex strings) into the format scanUnifiedNotes expects.
|
|
1407
|
+
*/
|
|
1408
|
+
export function parseAnnouncementsFromHex(rows: Array<{
|
|
1409
|
+
announcement_type: number;
|
|
1410
|
+
ephemeral_pub: string;
|
|
1411
|
+
encrypted_amount: string;
|
|
1412
|
+
commitment: string;
|
|
1413
|
+
leaf_index: number;
|
|
1414
|
+
token_id?: string | null;
|
|
1415
|
+
}>): Array<{
|
|
1416
|
+
announcementType: number;
|
|
1417
|
+
ephemeralPub: Uint8Array;
|
|
1418
|
+
encryptedAmount: Uint8Array;
|
|
1419
|
+
commitment: Uint8Array;
|
|
1420
|
+
leafIndex: number;
|
|
1421
|
+
tokenIdHex?: string;
|
|
1422
|
+
}> {
|
|
1423
|
+
return rows.map((r) => ({
|
|
1424
|
+
announcementType: r.announcement_type,
|
|
1425
|
+
ephemeralPub: hexToBytes(r.ephemeral_pub),
|
|
1426
|
+
encryptedAmount: hexToBytes(r.encrypted_amount),
|
|
1427
|
+
commitment: hexToBytes(r.commitment),
|
|
1428
|
+
leafIndex: r.leaf_index,
|
|
1429
|
+
tokenIdHex: r.token_id ?? undefined,
|
|
1430
|
+
}));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// ========== Deposit Ownership Check ==========
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Check if a deposit (identified by its OP_RETURN ephemeralPub + npk) belongs
|
|
1437
|
+
* to the given viewing key holder.
|
|
1438
|
+
*
|
|
1439
|
+
* Performs X25519 ECDH between the viewer's private key and the deposit's
|
|
1440
|
+
* ephemeral public key, derives the expected NPK, and compares it with the
|
|
1441
|
+
* deposit's actual NPK.
|
|
1442
|
+
*/
|
|
1443
|
+
export function isDepositForViewer(
|
|
1444
|
+
viewingPrivKey: Uint8Array,
|
|
1445
|
+
spendingPubKey: { x: bigint; y: bigint },
|
|
1446
|
+
nullifyingKey: bigint,
|
|
1447
|
+
ephemeralPub: Uint8Array,
|
|
1448
|
+
depositNpk: bigint,
|
|
1449
|
+
): boolean {
|
|
1450
|
+
try {
|
|
1451
|
+
const sharedSecret = x25519Ecdh(viewingPrivKey, ephemeralPub);
|
|
1452
|
+
const mpk = computeMPKSync(spendingPubKey.x, spendingPubKey.y, nullifyingKey);
|
|
1453
|
+
const stealthScalar = deriveStealthScalar(sharedSecret);
|
|
1454
|
+
const expectedNpk = computeNPKSync(mpk, stealthScalar);
|
|
1455
|
+
return expectedNpk === depositNpk;
|
|
1456
|
+
} catch {
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Check if a deposit belongs to this viewer — accepts hex string inputs.
|
|
1463
|
+
* Convenience wrapper around isDepositForViewer for frontend use.
|
|
1464
|
+
*/
|
|
1465
|
+
export function isDepositForViewerHex(
|
|
1466
|
+
keys: { viewingPrivKey: Uint8Array; spendingPubKey: { x: bigint; y: bigint }; nullifyingKey: bigint },
|
|
1467
|
+
ephemeralPubHex: string,
|
|
1468
|
+
npkHex: string,
|
|
1469
|
+
): boolean {
|
|
1470
|
+
try {
|
|
1471
|
+
const ephPub = hexToBytes(ephemeralPubHex);
|
|
1472
|
+
const npk = bytesToBigint(hexToBytes(npkHex));
|
|
1473
|
+
return isDepositForViewer(keys.viewingPrivKey, keys.spendingPubKey, keys.nullifyingKey, ephPub, npk);
|
|
1474
|
+
} catch {
|
|
1475
|
+
return false;
|
|
1476
|
+
}
|
|
1477
|
+
}
|