@utxopia/sdk 0.1.0-alpha.1 → 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.
Files changed (142) hide show
  1. package/README.md +214 -108
  2. package/{packages/sdk/dist → dist}/client.d.ts +25 -1
  3. package/{packages/sdk/dist → dist}/client.js +36 -1
  4. package/{packages/sdk/dist → dist}/config.d.ts +34 -3
  5. package/{packages/sdk/dist → dist}/config.js +88 -18
  6. package/{packages/sdk/dist → dist}/crypto-ed25519.d.ts +14 -0
  7. package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
  8. package/{packages/sdk/dist → dist}/index.d.ts +5 -4
  9. package/{packages/sdk/dist → dist}/index.js +5 -4
  10. package/{packages/sdk/dist → dist}/instructions.d.ts +88 -1
  11. package/{packages/sdk/dist → dist}/instructions.js +104 -3
  12. package/dist/spend-doc.d.ts +63 -0
  13. package/dist/spend-doc.js +100 -0
  14. package/{packages/sdk/dist → dist}/stealth.d.ts +124 -1
  15. package/{packages/sdk/dist → dist}/stealth.js +172 -4
  16. package/{packages/sdk/dist → dist}/taproot.d.ts +48 -0
  17. package/{packages/sdk/dist → dist}/taproot.js +71 -0
  18. package/package.json +86 -63
  19. package/src/announcement-client.ts +457 -0
  20. package/src/auditor-ciphertext.ts +181 -0
  21. package/src/auditor.ts +409 -0
  22. package/src/bitcoin/ika.ts +103 -0
  23. package/src/bitcoin/index.ts +5 -0
  24. package/src/bound-params.ts +322 -0
  25. package/src/chadbuffer.ts +603 -0
  26. package/src/circomlibjs.d.ts +51 -0
  27. package/src/claim-link.ts +53 -0
  28. package/src/client.ts +638 -0
  29. package/src/commitment-tree.ts +736 -0
  30. package/src/config.ts +772 -0
  31. package/src/core/esplora.ts +332 -0
  32. package/src/core/mempool.ts +159 -0
  33. package/src/crypto-babyjub.ts +385 -0
  34. package/src/crypto-ed25519.ts +297 -0
  35. package/src/crypto.ts +199 -0
  36. package/src/event-client.ts +231 -0
  37. package/src/events.ts +384 -0
  38. package/src/explorer.ts +300 -0
  39. package/src/index.ts +902 -0
  40. package/src/instructions.ts +2820 -0
  41. package/src/keys.ts +1228 -0
  42. package/src/logger.ts +41 -0
  43. package/src/magicblock.ts +278 -0
  44. package/src/merkle.ts +197 -0
  45. package/src/note.ts +754 -0
  46. package/src/pda.ts +516 -0
  47. package/src/pool-state.ts +176 -0
  48. package/src/poseidon.ts +175 -0
  49. package/src/prover/index.ts +19 -0
  50. package/src/prover/mobile.ts +303 -0
  51. package/src/prover/web.ts +771 -0
  52. package/src/psbt.ts +333 -0
  53. package/src/selective-disclosure.ts +284 -0
  54. package/src/sender-memo.ts +343 -0
  55. package/src/snarkjs.d.ts +19 -0
  56. package/src/sns-resolver.ts +333 -0
  57. package/src/solana/connection.ts +189 -0
  58. package/src/solana/priority-fee.ts +201 -0
  59. package/src/spend-doc.ts +163 -0
  60. package/src/stealth.ts +1477 -0
  61. package/src/taproot.ts +707 -0
  62. package/src/token-registry.ts +207 -0
  63. package/src/utils/encoding.ts +33 -0
  64. package/src/vk-registry.ts +295 -0
  65. package/LICENSE +0 -21
  66. package/packages/btc-client/src/esplora-client.ts +0 -153
  67. package/packages/btc-client/src/index.ts +0 -3
  68. package/packages/btc-client/src/op-return.ts +0 -93
  69. package/packages/btc-client/src/types.ts +0 -112
  70. package/packages/sdk/README.md +0 -277
  71. /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
  72. /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
  73. /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
  74. /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
  75. /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
  76. /package/{packages/sdk/dist → dist}/auditor.js +0 -0
  77. /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
  78. /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
  79. /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
  80. /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
  81. /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
  82. /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
  83. /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
  84. /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
  85. /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
  86. /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
  87. /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
  88. /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
  89. /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
  90. /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
  91. /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
  92. /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
  93. /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
  94. /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
  95. /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
  96. /package/{packages/sdk/dist → dist}/crypto.js +0 -0
  97. /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
  98. /package/{packages/sdk/dist → dist}/event-client.js +0 -0
  99. /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
  100. /package/{packages/sdk/dist → dist}/events.js +0 -0
  101. /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
  102. /package/{packages/sdk/dist → dist}/explorer.js +0 -0
  103. /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
  104. /package/{packages/sdk/dist → dist}/keys.js +0 -0
  105. /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
  106. /package/{packages/sdk/dist → dist}/logger.js +0 -0
  107. /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
  108. /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
  109. /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
  110. /package/{packages/sdk/dist → dist}/merkle.js +0 -0
  111. /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
  112. /package/{packages/sdk/dist → dist}/note.js +0 -0
  113. /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
  114. /package/{packages/sdk/dist → dist}/pda.js +0 -0
  115. /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
  116. /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
  117. /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
  118. /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
  119. /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
  120. /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
  121. /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
  122. /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
  123. /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
  124. /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
  125. /package/{packages/sdk/dist → dist}/psbt.d.ts +0 -0
  126. /package/{packages/sdk/dist → dist}/psbt.js +0 -0
  127. /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
  128. /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
  129. /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
  130. /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
  131. /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
  132. /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
  133. /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
  134. /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
  135. /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
  136. /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
  137. /package/{packages/sdk/dist → dist}/token-registry.d.ts +0 -0
  138. /package/{packages/sdk/dist → dist}/token-registry.js +0 -0
  139. /package/{packages/sdk/dist → dist}/utils/encoding.d.ts +0 -0
  140. /package/{packages/sdk/dist → dist}/utils/encoding.js +0 -0
  141. /package/{packages/sdk/dist → dist}/vk-registry.d.ts +0 -0
  142. /package/{packages/sdk/dist → dist}/vk-registry.js +0 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * "What am I proving?" — a canonical sentence for a shielded spend.
3
+ *
4
+ * The text is not the point. The point is that `renderSpendDoc` recomputes the
5
+ * proof's public signals from the numbers it is about to print and throws if
6
+ * they disagree, so a UI cannot caption a proof with an amount or a destination
7
+ * the proof does not actually contain.
8
+ *
9
+ * Nothing here changes the circuit. Every value below is already bound to the
10
+ * user's spending key by the in-circuit EdDSA over
11
+ * Poseidon(merkleRoot, boundParamsHash, nullifiers.., commitmentsOut..).
12
+ */
13
+ import { type SolanaPrivacyDomainContext } from "./bound-params";
14
+ export interface SpendDoc {
15
+ mode: "transfer" | "unshield" | "redeem";
16
+ /** Display label, e.g. "Solana Devnet". */
17
+ network: string;
18
+ /** Display label, e.g. "zkBTC". */
19
+ asset: string;
20
+ decimals: number;
21
+ /** Destination as shown to the user (a .sol name, a BTC address, a pubkey). */
22
+ recipient: string;
23
+ /**
24
+ * The destination bytes actually folded into boundParamsHash: 32-byte Solana
25
+ * owner for `unshield`, raw scriptPubKey for `redeem`. Omitted for `transfer`,
26
+ * where the destination is private and provably absent from the signals.
27
+ */
28
+ recipientBytes?: Uint8Array;
29
+ /** Raw units the recipient receives. */
30
+ amount: bigint;
31
+ relayerFee: bigint;
32
+ change: bigint;
33
+ }
34
+ /** The public signals of the proof about to be generated, plus what built them. */
35
+ export interface SpendSignals {
36
+ /** `outputs.map(o => o.value)` from JoinSplitProofInputs. */
37
+ outputValues: bigint[];
38
+ /** The boundParamsHash going into the proof. */
39
+ boundParamsHash: bigint;
40
+ stealthDataHash: Uint8Array;
41
+ chainId: bigint;
42
+ domain: SolanaPrivacyDomainContext;
43
+ /** Redeem only: the on-chain requester bound into the proof. */
44
+ requester?: Uint8Array;
45
+ treeNumber?: number;
46
+ }
47
+ export declare class SpendDocMismatch extends Error {
48
+ }
49
+ /**
50
+ * The statement itself. Show this before the user commits; pass the same `doc`
51
+ * to `renderSpendDoc` when the proof is built so the string they read is the
52
+ * string that gets checked.
53
+ */
54
+ export declare function formatSpendDoc(doc: SpendDoc): string;
55
+ /**
56
+ * Render the statement, or throw if it does not describe `signals`.
57
+ *
58
+ * Checked: every amount on screen is an output value of the proof and there are
59
+ * no other outputs; the destination reproduces boundParamsHash.
60
+ * Not checked: that `recipient` (a label) names `recipientBytes` — the doc
61
+ * prints the bound bytes so that stays verifiable by eye.
62
+ */
63
+ export declare function renderSpendDoc(doc: SpendDoc, signals: SpendSignals): string;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * "What am I proving?" — a canonical sentence for a shielded spend.
3
+ *
4
+ * The text is not the point. The point is that `renderSpendDoc` recomputes the
5
+ * proof's public signals from the numbers it is about to print and throws if
6
+ * they disagree, so a UI cannot caption a proof with an amount or a destination
7
+ * the proof does not actually contain.
8
+ *
9
+ * Nothing here changes the circuit. Every value below is already bound to the
10
+ * user's spending key by the in-circuit EdDSA over
11
+ * Poseidon(merkleRoot, boundParamsHash, nullifiers.., commitmentsOut..).
12
+ */
13
+ import { computeSolanaDomainBoundParamsHash, createRedeemBoundParams, createTransferBoundParams, createUnshieldBoundParams, } from "./bound-params";
14
+ export class SpendDocMismatch extends Error {
15
+ }
16
+ function fmt(raw, decimals) {
17
+ const neg = raw < 0n;
18
+ const s = (neg ? -raw : raw).toString().padStart(decimals + 1, "0");
19
+ const whole = s.slice(0, s.length - decimals);
20
+ const frac = decimals === 0 ? "" : s.slice(s.length - decimals).replace(/0+$/, "");
21
+ return `${neg ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`;
22
+ }
23
+ const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
24
+ function sortedValues(v) {
25
+ return [...v].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(",");
26
+ }
27
+ function expectedBoundParamsHash(doc, s) {
28
+ const tree = s.treeNumber ?? 0;
29
+ switch (doc.mode) {
30
+ case "transfer":
31
+ return computeSolanaDomainBoundParamsHash(createTransferBoundParams(s.stealthDataHash, s.chainId, tree), s.domain);
32
+ case "unshield":
33
+ if (!doc.recipientBytes)
34
+ throw new SpendDocMismatch("unshield doc has no recipientBytes");
35
+ return computeSolanaDomainBoundParamsHash(createUnshieldBoundParams(doc.recipientBytes, s.stealthDataHash, s.chainId, tree), s.domain);
36
+ case "redeem":
37
+ if (!doc.recipientBytes)
38
+ throw new SpendDocMismatch("redeem doc has no recipientBytes");
39
+ if (!s.requester)
40
+ throw new SpendDocMismatch("redeem doc has no requester");
41
+ return computeSolanaDomainBoundParamsHash(createRedeemBoundParams(doc.recipientBytes, s.stealthDataHash, s.requester, s.chainId, tree), s.domain);
42
+ }
43
+ }
44
+ /**
45
+ * The statement itself. Show this before the user commits; pass the same `doc`
46
+ * to `renderSpendDoc` when the proof is built so the string they read is the
47
+ * string that gets checked.
48
+ */
49
+ export function formatSpendDoc(doc) {
50
+ const amt = (v) => `${fmt(v, doc.decimals)} ${doc.asset}`;
51
+ const action = doc.mode === "redeem"
52
+ ? `Withdraw ${amt(doc.amount)} to Bitcoin`
53
+ : doc.mode === "unshield"
54
+ ? `Unshield ${amt(doc.amount)}`
55
+ : `Send ${amt(doc.amount)} privately`;
56
+ const lines = [
57
+ "UTXOpia Proof",
58
+ "",
59
+ "I AM PROVING",
60
+ action,
61
+ "",
62
+ "DETAILS",
63
+ `Network: ${doc.network}`,
64
+ `Amount leaving the pool: ${amt(doc.amount)}`,
65
+ `To: ${doc.recipient}`,
66
+ ];
67
+ if (doc.recipientBytes)
68
+ lines.push(`Bound destination: ${hex(doc.recipientBytes)}`);
69
+ if (doc.relayerFee > 0n)
70
+ lines.push(`Relayer fee: ${amt(doc.relayerFee)}`);
71
+ if (doc.change > 0n)
72
+ lines.push(`Change back to me: ${amt(doc.change)}`);
73
+ lines.push("", "ENFORCED BY THE PROOF", "Amounts: these are every output this proof creates", doc.mode === "transfer"
74
+ ? "Destination: private — not in the public signals. Confirm it with the recipient."
75
+ : "Destination: bound into boundParamsHash and re-derived onchain");
76
+ if (doc.mode !== "transfer") {
77
+ lines.push("Protocol fee: deducted onchain from pool policy, not part of this statement");
78
+ }
79
+ lines.push("", "Protocol: utxopia-spend-doc-v1");
80
+ return lines.join("\n");
81
+ }
82
+ /**
83
+ * Render the statement, or throw if it does not describe `signals`.
84
+ *
85
+ * Checked: every amount on screen is an output value of the proof and there are
86
+ * no other outputs; the destination reproduces boundParamsHash.
87
+ * Not checked: that `recipient` (a label) names `recipientBytes` — the doc
88
+ * prints the bound bytes so that stays verifiable by eye.
89
+ */
90
+ export function renderSpendDoc(doc, signals) {
91
+ const shown = [doc.amount, doc.relayerFee, doc.change].filter((v) => v > 0n);
92
+ if (sortedValues(shown) !== sortedValues(signals.outputValues)) {
93
+ throw new SpendDocMismatch(`amounts do not match the proof outputs: doc [${sortedValues(shown)}] vs proof [${sortedValues(signals.outputValues)}]`);
94
+ }
95
+ const expected = expectedBoundParamsHash(doc, signals);
96
+ if (expected !== signals.boundParamsHash) {
97
+ throw new SpendDocMismatch("destination does not match the proof's boundParamsHash");
98
+ }
99
+ return formatSpendDoc(doc);
100
+ }
@@ -127,7 +127,7 @@ export interface OnChainStealthAnnouncement {
127
127
  * 6. commitment = Poseidon(npk, tokenId, amount)
128
128
  * 7. encryptedAmount = amount XOR sha256(sharedSecret)[0..8]
129
129
  */
130
- export declare function createStealthDeposit(recipientMeta: StealthMetaAddress, amountSats: bigint, tokenId: bigint): Promise<StealthDeposit>;
130
+ export declare function createStealthDeposit(recipientMeta: StealthMetaAddress, amountSats: bigint, tokenId: bigint, outgoing?: OutgoingRecoveryMaterial): Promise<StealthDeposit>;
131
131
  /**
132
132
  * Extended stealth output data including the derived stealth pub key
133
133
  */
@@ -200,6 +200,129 @@ export declare function createNonInteractiveDeposit(recipientMeta: StealthMetaAd
200
200
  * credits the note from that transaction.
201
201
  */
202
202
  export declare function createDirectVaultDeposit(recipientMeta: StealthMetaAddress, vaultXOnlyPubkey: Uint8Array, network?: "mainnet" | "testnet" | "regtest", opReturnContext?: DepositOpReturnContext): Promise<NonInteractiveDepositResult>;
203
+ /**
204
+ * The delegable half of deposit recovery.
205
+ *
206
+ * Deposit addresses are derived from this node, so whoever holds it can rebuild
207
+ * every address the owner was ever handed, and with it every tapleaf and control
208
+ * block needed to spend those coins. It grants no spend authority over notes:
209
+ * that needs the spending key and the nullifying key, neither of which is
210
+ * derivable from here.
211
+ *
212
+ * Derived from the viewing key rather than the master seed on purpose. It means
213
+ * an owner can hand a chosen party — another device, a custodian, the pool
214
+ * operator — the ability to recover their BTC without handing over the ability
215
+ * to spend their notes. Same split Fluidkey uses for its ephemeral key nodes.
216
+ */
217
+ export declare function depositViewingNode(viewingPrivKey: Uint8Array): Uint8Array;
218
+ /**
219
+ * The delegable half of *outgoing* history.
220
+ *
221
+ * An announcement is encrypted to the recipient's viewing key, and the sender
222
+ * discards the ephemeral private key — so today a sender cannot rediscover what
223
+ * they paid out. Their history exists only in local storage. Indexing the
224
+ * ephemeral key off this node makes every outgoing payment recomputable from
225
+ * keys alone. Zcash calls the equivalent an outgoing viewing key.
226
+ *
227
+ * Deliberately a different node from `depositViewingNode`. The two authorise
228
+ * different things, and one should not smuggle in the other:
229
+ *
230
+ * - deposit node → "you can recover my BTC"
231
+ * - outgoing node → "you can see who I paid"
232
+ */
233
+ export declare function outgoingViewingNode(viewingPrivKey: Uint8Array): Uint8Array;
234
+ /** Which outgoing payment an ephemeral key belongs to. */
235
+ export interface OutgoingRecoveryMaterial {
236
+ /** From `outgoingViewingNode(viewingPrivKey)`. */
237
+ outgoingNode: Uint8Array;
238
+ /** Monotonic per-sender counter. One payment per index. */
239
+ sendIndex: number;
240
+ }
241
+ /** `sha256(domain || outgoingNode || sendIndex)` → Ed25519 ephemeral keypair. */
242
+ export declare function outgoingEphemeralKeyPair(outgoing: OutgoingRecoveryMaterial): {
243
+ privKey: Uint8Array;
244
+ pubKey: Uint8Array;
245
+ };
246
+ /**
247
+ * Recover the next unused send index by walking 0 upward against the ephemeral
248
+ * pubkeys already on chain.
249
+ *
250
+ * An index is "used" when some announcement carries the ephemeral pubkey it
251
+ * derives. Scanning stops after `gapLimit` consecutive misses, because a
252
+ * derived-but-never-broadcast payment leaves a hole — an abandoned or failed
253
+ * transaction — and stopping at the first hole would hand back an index that is
254
+ * already spoken for.
255
+ *
256
+ * This is a FLOOR, not the live counter. A payment broadcast but not yet indexed
257
+ * is invisible here, so a sender that keeps local state must take
258
+ * `max(localCounter, findNextSendIndex(...))`. Reusing an index re-derives the
259
+ * same ephemeral key, and to the same recipient that means the same note
260
+ * commitment twice.
261
+ */
262
+ export declare function findNextSendIndex(outgoingNode: Uint8Array, seenEphemeralPubs: Iterable<Uint8Array>, gapLimit?: number): number;
263
+ /**
264
+ * What makes a deposit address reconstructable.
265
+ *
266
+ * The ephemeral key is NOT random. A deposit address commits to it via the
267
+ * tapleaf, and the key path is a NUMS point, so an address whose ephemeral key
268
+ * is lost is an address nobody — not the owner, not the pool — can ever spend.
269
+ * Indexing it off the viewing node makes that node a complete backup: walk
270
+ * `depositIndex` upward and every address comes back.
271
+ */
272
+ export interface DepositRecoveryMaterial {
273
+ /** From `depositViewingNode(viewingPrivKey)`. */
274
+ viewingNode: Uint8Array;
275
+ /** Monotonic per-owner counter. One address per index. */
276
+ depositIndex: number;
277
+ }
278
+ /** `sha256(domain || viewingNode || depositIndex)` → Ed25519 ephemeral keypair. */
279
+ export declare function depositEphemeralKeyPair(recovery: DepositRecoveryMaterial): {
280
+ privKey: Uint8Array;
281
+ pubKey: Uint8Array;
282
+ };
283
+ /** A deposit whose address alone binds the note keys — no OP_RETURN. */
284
+ export interface TweakDepositResult {
285
+ /** Taproot address to send BTC to */
286
+ btcAddress: string;
287
+ /** 32-byte x-only output key for the deposit P2TR output */
288
+ depositOutputKey: Uint8Array;
289
+ /** 32-byte note public key */
290
+ npk: Uint8Array;
291
+ /** 32-byte Ed25519 ephemeral public key */
292
+ ephemeralPub: Uint8Array;
293
+ /** sha256(npk || ephemeralPub) — the commitment carried in the tapleaf */
294
+ tweakCommitment: Uint8Array;
295
+ /** The tapleaf: `<commitment> OP_DROP <ika_xonly> OP_CHECKSIG` */
296
+ leafScript: Uint8Array;
297
+ /** Its BIP-341 tapleaf hash, which is also the merkle root (single leaf) */
298
+ leafHash: Uint8Array;
299
+ /** Script-path witness is `[signature, leafScript, controlBlock]` */
300
+ controlBlock: Uint8Array;
301
+ }
302
+ /**
303
+ * Create a deposit for the OP_RETURN-free flow (`verify_deposit`, disc 25).
304
+ *
305
+ * The transaction carries nothing but a payment, so anything that can send to a
306
+ * P2TR address can fund it — a hardware wallet, an exchange withdrawal, a faucet.
307
+ * The note keys are recovered from instruction data at completion time and proven
308
+ * against this address's tapleaf, so substituting either key derives a different
309
+ * leaf, and so a different address that the funding transaction never paid.
310
+ *
311
+ * The address is spendable only by `vaultXOnlyPubkey` via the script path; its
312
+ * key path is a NUMS point. That keeps the deposit under Ika custody from the
313
+ * moment it confirms, and it is also the only shape Ika can sign for — its MPC
314
+ * cannot produce a signature for a tweaked key.
315
+ *
316
+ * `recovery` is not optional on purpose. The address commits to the ephemeral
317
+ * key and the key path is unspendable, so a random ephemeral key that is later
318
+ * lost burns the coins outright. Indexing it off the viewing node means that
319
+ * node is the backup — and it can be delegated without granting spend authority.
320
+ *
321
+ * Register the address with the tracker BEFORE any coins are sent — a deposit
322
+ * with no OP_RETURN is invisible to block scanning, so an unregistered address
323
+ * is one nobody is watching.
324
+ */
325
+ export declare function createTweakDeposit(recipientMeta: StealthMetaAddress, vaultXOnlyPubkey: Uint8Array, recovery: DepositRecoveryMaterial, network?: "mainnet" | "testnet" | "regtest"): Promise<TweakDepositResult>;
203
326
  /**
204
327
  * Create a non-interactive deposit using the current SDK config.
205
328
  *
@@ -42,7 +42,7 @@ function isKnownAnnouncementType(value) {
42
42
  // ========== Imports ==========
43
43
  import { sha256 } from "@noble/hashes/sha2.js";
44
44
  import { bigintToBytes, bytesToBigint, bytesToHex, hexToBytes, babyJubMul, babyJubAdd, babyJubCompress, babyJubDecompress, BABYJUB_BASE8, BABYJUB_ORDER, scalarFromBytes, } from "./crypto";
45
- import { ed25519GenerateKeyPair, x25519Ecdh, encryptAmountEd25519, decryptAmountEd25519, } from "./crypto-ed25519";
45
+ import { ed25519GenerateKeyPair, ed25519KeyPairFromMaterial, x25519Ecdh, encryptAmountEd25519, decryptAmountEd25519, } from "./crypto-ed25519";
46
46
  import { deriveKeysFromWallet } from "./keys";
47
47
  import { computeMPKSync, computeNPKSync, computeJoinSplitCommitmentSync, computeJoinSplitNullifierSync, } from "./poseidon";
48
48
  import { getConfig } from "./config";
@@ -124,11 +124,17 @@ function deriveStealthPrivKey(spendingPriv, sharedSecret) {
124
124
  * 6. commitment = Poseidon(npk, tokenId, amount)
125
125
  * 7. encryptedAmount = amount XOR sha256(sharedSecret)[0..8]
126
126
  */
127
- export async function createStealthDeposit(recipientMeta, amountSats, tokenId) {
127
+ export async function createStealthDeposit(recipientMeta, amountSats, tokenId, outgoing) {
128
128
  // Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
129
129
  const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
130
- // Generate Ed25519 ephemeral keypair
131
- const ephemeral = ed25519GenerateKeyPair();
130
+ // Indexed off the sender's outgoing node when supplied, so they can recompute
131
+ // this payment later; random otherwise, which works but leaves no trace the
132
+ // sender can recover. Optional rather than required because the cost of
133
+ // omitting it is a lost record, not lost funds — unlike a deposit address,
134
+ // where the ephemeral key IS the only way to ever spend the coins.
135
+ const ephemeral = outgoing
136
+ ? outgoingEphemeralKeyPair(outgoing)
137
+ : ed25519GenerateKeyPair();
132
138
  // X25519 ECDH: shared secret
133
139
  const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
134
140
  // Derive stealth scalar as the random value for NPK
@@ -248,6 +254,168 @@ export async function createDirectVaultDeposit(recipientMeta, vaultXOnlyPubkey,
248
254
  ephemeralPub,
249
255
  };
250
256
  }
257
+ const DEPOSIT_VIEWING_NODE_DOMAIN = new TextEncoder().encode("utxopia:deposit-viewing-node:v1");
258
+ const DEPOSIT_EPHEMERAL_DOMAIN = new TextEncoder().encode("utxopia:deposit-ephemeral:v1");
259
+ /**
260
+ * The delegable half of deposit recovery.
261
+ *
262
+ * Deposit addresses are derived from this node, so whoever holds it can rebuild
263
+ * every address the owner was ever handed, and with it every tapleaf and control
264
+ * block needed to spend those coins. It grants no spend authority over notes:
265
+ * that needs the spending key and the nullifying key, neither of which is
266
+ * derivable from here.
267
+ *
268
+ * Derived from the viewing key rather than the master seed on purpose. It means
269
+ * an owner can hand a chosen party — another device, a custodian, the pool
270
+ * operator — the ability to recover their BTC without handing over the ability
271
+ * to spend their notes. Same split Fluidkey uses for its ephemeral key nodes.
272
+ */
273
+ export function depositViewingNode(viewingPrivKey) {
274
+ if (viewingPrivKey.length === 0) {
275
+ throw new Error("viewingPrivKey must not be empty");
276
+ }
277
+ const material = new Uint8Array(DEPOSIT_VIEWING_NODE_DOMAIN.length + viewingPrivKey.length);
278
+ material.set(DEPOSIT_VIEWING_NODE_DOMAIN, 0);
279
+ material.set(viewingPrivKey, DEPOSIT_VIEWING_NODE_DOMAIN.length);
280
+ return sha256(material);
281
+ }
282
+ const OUTGOING_VIEWING_NODE_DOMAIN = new TextEncoder().encode("utxopia:outgoing-viewing-node:v1");
283
+ const OUTGOING_EPHEMERAL_DOMAIN = new TextEncoder().encode("utxopia:outgoing-ephemeral:v1");
284
+ /**
285
+ * The delegable half of *outgoing* history.
286
+ *
287
+ * An announcement is encrypted to the recipient's viewing key, and the sender
288
+ * discards the ephemeral private key — so today a sender cannot rediscover what
289
+ * they paid out. Their history exists only in local storage. Indexing the
290
+ * ephemeral key off this node makes every outgoing payment recomputable from
291
+ * keys alone. Zcash calls the equivalent an outgoing viewing key.
292
+ *
293
+ * Deliberately a different node from `depositViewingNode`. The two authorise
294
+ * different things, and one should not smuggle in the other:
295
+ *
296
+ * - deposit node → "you can recover my BTC"
297
+ * - outgoing node → "you can see who I paid"
298
+ */
299
+ export function outgoingViewingNode(viewingPrivKey) {
300
+ if (viewingPrivKey.length === 0) {
301
+ throw new Error("viewingPrivKey must not be empty");
302
+ }
303
+ const material = new Uint8Array(OUTGOING_VIEWING_NODE_DOMAIN.length + viewingPrivKey.length);
304
+ material.set(OUTGOING_VIEWING_NODE_DOMAIN, 0);
305
+ material.set(viewingPrivKey, OUTGOING_VIEWING_NODE_DOMAIN.length);
306
+ return sha256(material);
307
+ }
308
+ /** `sha256(domain || outgoingNode || sendIndex)` → Ed25519 ephemeral keypair. */
309
+ export function outgoingEphemeralKeyPair(outgoing) {
310
+ const { outgoingNode, sendIndex } = outgoing;
311
+ if (outgoingNode.length === 0) {
312
+ throw new Error("outgoingNode must not be empty");
313
+ }
314
+ if (!Number.isInteger(sendIndex) || sendIndex < 0) {
315
+ throw new Error(`invalid sendIndex: ${sendIndex}`);
316
+ }
317
+ const material = new Uint8Array(OUTGOING_EPHEMERAL_DOMAIN.length + outgoingNode.length + 4);
318
+ material.set(OUTGOING_EPHEMERAL_DOMAIN, 0);
319
+ material.set(outgoingNode, OUTGOING_EPHEMERAL_DOMAIN.length);
320
+ new DataView(material.buffer).setUint32(OUTGOING_EPHEMERAL_DOMAIN.length + outgoingNode.length, sendIndex, true);
321
+ return ed25519KeyPairFromMaterial(material);
322
+ }
323
+ /**
324
+ * Recover the next unused send index by walking 0 upward against the ephemeral
325
+ * pubkeys already on chain.
326
+ *
327
+ * An index is "used" when some announcement carries the ephemeral pubkey it
328
+ * derives. Scanning stops after `gapLimit` consecutive misses, because a
329
+ * derived-but-never-broadcast payment leaves a hole — an abandoned or failed
330
+ * transaction — and stopping at the first hole would hand back an index that is
331
+ * already spoken for.
332
+ *
333
+ * This is a FLOOR, not the live counter. A payment broadcast but not yet indexed
334
+ * is invisible here, so a sender that keeps local state must take
335
+ * `max(localCounter, findNextSendIndex(...))`. Reusing an index re-derives the
336
+ * same ephemeral key, and to the same recipient that means the same note
337
+ * commitment twice.
338
+ */
339
+ export function findNextSendIndex(outgoingNode, seenEphemeralPubs, gapLimit = 20) {
340
+ const seen = new Set();
341
+ for (const pub of seenEphemeralPubs)
342
+ seen.add(bytesToHex(pub));
343
+ let highestUsed = -1;
344
+ let misses = 0;
345
+ for (let index = 0; misses <= gapLimit; index++) {
346
+ if (seen.has(bytesToHex(outgoingEphemeralKeyPair({ outgoingNode, sendIndex: index }).pubKey))) {
347
+ highestUsed = index;
348
+ misses = 0;
349
+ }
350
+ else {
351
+ misses++;
352
+ }
353
+ }
354
+ return highestUsed + 1;
355
+ }
356
+ /** `sha256(domain || viewingNode || depositIndex)` → Ed25519 ephemeral keypair. */
357
+ export function depositEphemeralKeyPair(recovery) {
358
+ const { viewingNode, depositIndex } = recovery;
359
+ if (viewingNode.length === 0) {
360
+ throw new Error("deposit viewingNode must not be empty");
361
+ }
362
+ if (!Number.isInteger(depositIndex) || depositIndex < 0) {
363
+ throw new Error(`invalid depositIndex: ${depositIndex}`);
364
+ }
365
+ const material = new Uint8Array(DEPOSIT_EPHEMERAL_DOMAIN.length + viewingNode.length + 4);
366
+ material.set(DEPOSIT_EPHEMERAL_DOMAIN, 0);
367
+ material.set(viewingNode, DEPOSIT_EPHEMERAL_DOMAIN.length);
368
+ new DataView(material.buffer).setUint32(DEPOSIT_EPHEMERAL_DOMAIN.length + viewingNode.length, depositIndex, true);
369
+ return ed25519KeyPairFromMaterial(material);
370
+ }
371
+ /**
372
+ * Create a deposit for the OP_RETURN-free flow (`verify_deposit`, disc 25).
373
+ *
374
+ * The transaction carries nothing but a payment, so anything that can send to a
375
+ * P2TR address can fund it — a hardware wallet, an exchange withdrawal, a faucet.
376
+ * The note keys are recovered from instruction data at completion time and proven
377
+ * against this address's tapleaf, so substituting either key derives a different
378
+ * leaf, and so a different address that the funding transaction never paid.
379
+ *
380
+ * The address is spendable only by `vaultXOnlyPubkey` via the script path; its
381
+ * key path is a NUMS point. That keeps the deposit under Ika custody from the
382
+ * moment it confirms, and it is also the only shape Ika can sign for — its MPC
383
+ * cannot produce a signature for a tweaked key.
384
+ *
385
+ * `recovery` is not optional on purpose. The address commits to the ephemeral
386
+ * key and the key path is unspendable, so a random ephemeral key that is later
387
+ * lost burns the coins outright. Indexing it off the viewing node means that
388
+ * node is the backup — and it can be delegated without granting spend authority.
389
+ *
390
+ * Register the address with the tracker BEFORE any coins are sent — a deposit
391
+ * with no OP_RETURN is invisible to block scanning, so an unregistered address
392
+ * is one nobody is watching.
393
+ */
394
+ export async function createTweakDeposit(recipientMeta, vaultXOnlyPubkey, recovery, network = "testnet") {
395
+ if (vaultXOnlyPubkey.length !== 32) {
396
+ throw new Error("vaultXOnlyPubkey must be 32 bytes");
397
+ }
398
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
399
+ const ephemeral = depositEphemeralKeyPair(recovery);
400
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
401
+ const stealthScalar = deriveStealthScalar(sharedSecret);
402
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
403
+ const npk = bigintToBytes(computeNPKSync(recipientMPK, stealthScalar));
404
+ const ephemeralPub = new Uint8Array(ephemeral.pubKey);
405
+ const { depositTweakCommitment, deriveDepositAddress } = await import("./taproot");
406
+ const tweakCommitment = depositTweakCommitment(npk, ephemeralPub);
407
+ const { address, outputKey, leafScript, leafHash, controlBlock } = deriveDepositAddress(tweakCommitment, vaultXOnlyPubkey, network);
408
+ return {
409
+ btcAddress: address,
410
+ depositOutputKey: outputKey,
411
+ npk,
412
+ ephemeralPub,
413
+ tweakCommitment,
414
+ leafScript,
415
+ leafHash,
416
+ controlBlock,
417
+ };
418
+ }
251
419
  /**
252
420
  * Create a non-interactive deposit using the current SDK config.
253
421
  *
@@ -22,6 +22,54 @@ export declare function deriveTaprootAddress(commitment: Uint8Array, network?: "
22
22
  address: string;
23
23
  outputKey: Uint8Array;
24
24
  tweak: Uint8Array;
25
+ /** y-parity of the output key — the low bit of a script-path control block. */
26
+ parity: number;
27
+ };
28
+ /**
29
+ * Per-deposit commitment carried in a `verify_deposit` (disc 25) address's tapleaf.
30
+ *
31
+ * Both keys are hashed in. Binding the note key alone would leave the ephemeral
32
+ * pubkey caller-chosen on the Solana side: the credited amount and owner would
33
+ * still be right, but a substituted ephemeral key makes the stealth announcement
34
+ * undecryptable and the recipient never finds their note.
35
+ *
36
+ * Feed the result to `deriveDepositAddress`.
37
+ */
38
+ export declare function depositTweakCommitment(notePublicKey: Uint8Array, ephemeralPubkey: Uint8Array): Uint8Array;
39
+ /**
40
+ * BIP-341's suggested NUMS point, used as the deposit address's internal key.
41
+ *
42
+ * Nobody knows its discrete log, so the key path is unspendable and custody
43
+ * rests entirely on the script path. That is deliberate: Ika's MPC cannot sign
44
+ * for a tweaked key, so an address whose internal key were the dWallet key plus
45
+ * a per-deposit tweak would be unspendable by the custodian meant to sweep it.
46
+ */
47
+ export declare const DEPOSIT_NUMS_INTERNAL_KEY: Uint8Array<ArrayBufferLike>;
48
+ /**
49
+ * The single tapleaf a deposit address commits to:
50
+ *
51
+ * ```text
52
+ * <commitment> OP_DROP <ika_xonly> OP_CHECKSIG
53
+ * ```
54
+ *
55
+ * The commitment rides in the script purely to make the leaf — and so the
56
+ * address — unique per deposit; `OP_DROP` discards it at spend time. Only the
57
+ * dWallet key can satisfy the `OP_CHECKSIG`, and it signs untweaked.
58
+ */
59
+ export declare function depositLeafScript(commitment: Uint8Array, ikaXOnlyPubkey: Uint8Array): Uint8Array;
60
+ /**
61
+ * Derive a deposit address bound to `commitment`, spendable only by the pool's
62
+ * Ika dWallet via the script path.
63
+ *
64
+ * Returns everything the sweeper needs to spend it: a script-path witness is
65
+ * `[signature, leafScript, controlBlock]`.
66
+ */
67
+ export declare function deriveDepositAddress(commitment: Uint8Array, ikaXOnlyPubkey: Uint8Array, network?: "mainnet" | "testnet" | "regtest"): {
68
+ address: string;
69
+ outputKey: Uint8Array;
70
+ leafScript: Uint8Array;
71
+ leafHash: Uint8Array;
72
+ controlBlock: Uint8Array;
25
73
  };
26
74
  /**
27
75
  * Verify that a Taproot address is correctly derived from a commitment
@@ -68,8 +68,79 @@ export function deriveTaprootAddress(commitment, network = "testnet", internalKe
68
68
  address,
69
69
  outputKey,
70
70
  tweak,
71
+ parity: outputKeyHex.startsWith("03") ? 1 : 0,
71
72
  };
72
73
  }
74
+ /**
75
+ * Per-deposit commitment carried in a `verify_deposit` (disc 25) address's tapleaf.
76
+ *
77
+ * Both keys are hashed in. Binding the note key alone would leave the ephemeral
78
+ * pubkey caller-chosen on the Solana side: the credited amount and owner would
79
+ * still be right, but a substituted ephemeral key makes the stealth announcement
80
+ * undecryptable and the recipient never finds their note.
81
+ *
82
+ * Feed the result to `deriveDepositAddress`.
83
+ */
84
+ export function depositTweakCommitment(notePublicKey, ephemeralPubkey) {
85
+ if (notePublicKey.length !== 32 || ephemeralPubkey.length !== 32) {
86
+ throw new Error("notePublicKey and ephemeralPubkey must be 32 bytes");
87
+ }
88
+ const material = new Uint8Array(64);
89
+ material.set(notePublicKey, 0);
90
+ material.set(ephemeralPubkey, 32);
91
+ return sha256(material);
92
+ }
93
+ /**
94
+ * BIP-341's suggested NUMS point, used as the deposit address's internal key.
95
+ *
96
+ * Nobody knows its discrete log, so the key path is unspendable and custody
97
+ * rests entirely on the script path. That is deliberate: Ika's MPC cannot sign
98
+ * for a tweaked key, so an address whose internal key were the dWallet key plus
99
+ * a per-deposit tweak would be unspendable by the custodian meant to sweep it.
100
+ */
101
+ export const DEPOSIT_NUMS_INTERNAL_KEY = hexToBytes("50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0");
102
+ /**
103
+ * The single tapleaf a deposit address commits to:
104
+ *
105
+ * ```text
106
+ * <commitment> OP_DROP <ika_xonly> OP_CHECKSIG
107
+ * ```
108
+ *
109
+ * The commitment rides in the script purely to make the leaf — and so the
110
+ * address — unique per deposit; `OP_DROP` discards it at spend time. Only the
111
+ * dWallet key can satisfy the `OP_CHECKSIG`, and it signs untweaked.
112
+ */
113
+ export function depositLeafScript(commitment, ikaXOnlyPubkey) {
114
+ if (commitment.length !== 32 || ikaXOnlyPubkey.length !== 32) {
115
+ throw new Error("commitment and ikaXOnlyPubkey must be 32 bytes");
116
+ }
117
+ const script = new Uint8Array(68);
118
+ script[0] = 0x20; // push 32 bytes
119
+ script.set(commitment, 1);
120
+ script[33] = 0x75; // OP_DROP
121
+ script[34] = 0x20; // push 32 bytes
122
+ script.set(ikaXOnlyPubkey, 35);
123
+ script[67] = 0xac; // OP_CHECKSIG
124
+ return script;
125
+ }
126
+ /**
127
+ * Derive a deposit address bound to `commitment`, spendable only by the pool's
128
+ * Ika dWallet via the script path.
129
+ *
130
+ * Returns everything the sweeper needs to spend it: a script-path witness is
131
+ * `[signature, leafScript, controlBlock]`.
132
+ */
133
+ export function deriveDepositAddress(commitment, ikaXOnlyPubkey, network = "testnet") {
134
+ const leafScript = depositLeafScript(commitment, ikaXOnlyPubkey);
135
+ // One leaf, so the leaf hash IS the merkle root.
136
+ const leafHash = computeTapLeafHash(leafScript);
137
+ const { address, outputKey, parity } = deriveTaprootAddress(leafHash, network, DEPOSIT_NUMS_INTERNAL_KEY);
138
+ // <leaf_version | parity> || internal_key. No merkle path: single leaf.
139
+ const controlBlock = new Uint8Array(33);
140
+ controlBlock[0] = 0xc0 | parity;
141
+ controlBlock.set(DEPOSIT_NUMS_INTERNAL_KEY, 1);
142
+ return { address, outputKey, leafScript, leafHash, controlBlock };
143
+ }
73
144
  /**
74
145
  * Verify that a Taproot address is correctly derived from a commitment
75
146
  *