@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.
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}/crypto-ed25519.d.ts +14 -0
  5. package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
  6. package/{packages/sdk/dist → dist}/index.d.ts +3 -3
  7. package/{packages/sdk/dist → dist}/index.js +3 -3
  8. package/{packages/sdk/dist → dist}/instructions.d.ts +28 -11
  9. package/{packages/sdk/dist → dist}/instructions.js +45 -15
  10. package/{packages/sdk/dist → dist}/stealth.d.ts +104 -9
  11. package/{packages/sdk/dist → dist}/stealth.js +146 -14
  12. package/{packages/sdk/dist → dist}/taproot.d.ts +39 -2
  13. package/{packages/sdk/dist → dist}/taproot.js +54 -2
  14. package/package.json +86 -63
  15. package/src/announcement-client.ts +457 -0
  16. package/src/auditor-ciphertext.ts +181 -0
  17. package/src/auditor.ts +409 -0
  18. package/src/bitcoin/ika.ts +103 -0
  19. package/src/bitcoin/index.ts +5 -0
  20. package/src/bound-params.ts +322 -0
  21. package/src/chadbuffer.ts +603 -0
  22. package/src/circomlibjs.d.ts +51 -0
  23. package/src/claim-link.ts +53 -0
  24. package/src/client.ts +638 -0
  25. package/src/commitment-tree.ts +736 -0
  26. package/src/config.ts +772 -0
  27. package/src/core/esplora.ts +332 -0
  28. package/src/core/mempool.ts +159 -0
  29. package/src/crypto-babyjub.ts +385 -0
  30. package/src/crypto-ed25519.ts +297 -0
  31. package/src/crypto.ts +199 -0
  32. package/src/event-client.ts +231 -0
  33. package/src/events.ts +384 -0
  34. package/src/explorer.ts +300 -0
  35. package/src/index.ts +902 -0
  36. package/src/instructions.ts +2820 -0
  37. package/src/keys.ts +1228 -0
  38. package/src/logger.ts +41 -0
  39. package/src/magicblock.ts +278 -0
  40. package/src/merkle.ts +197 -0
  41. package/src/note.ts +754 -0
  42. package/src/pda.ts +516 -0
  43. package/src/pool-state.ts +176 -0
  44. package/src/poseidon.ts +175 -0
  45. package/src/prover/index.ts +19 -0
  46. package/src/prover/mobile.ts +303 -0
  47. package/src/prover/web.ts +771 -0
  48. package/src/psbt.ts +333 -0
  49. package/src/selective-disclosure.ts +284 -0
  50. package/src/sender-memo.ts +343 -0
  51. package/src/snarkjs.d.ts +19 -0
  52. package/src/sns-resolver.ts +333 -0
  53. package/src/solana/connection.ts +189 -0
  54. package/src/solana/priority-fee.ts +201 -0
  55. package/src/spend-doc.ts +163 -0
  56. package/src/stealth.ts +1477 -0
  57. package/src/taproot.ts +707 -0
  58. package/src/token-registry.ts +207 -0
  59. package/src/utils/encoding.ts +33 -0
  60. package/src/vk-registry.ts +295 -0
  61. package/LICENSE +0 -21
  62. package/packages/btc-client/src/esplora-client.ts +0 -153
  63. package/packages/btc-client/src/index.ts +0 -3
  64. package/packages/btc-client/src/op-return.ts +0 -93
  65. package/packages/btc-client/src/types.ts +0 -112
  66. package/packages/sdk/README.md +0 -277
  67. /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
  68. /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
  69. /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
  70. /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
  71. /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
  72. /package/{packages/sdk/dist → dist}/auditor.js +0 -0
  73. /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
  74. /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
  75. /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
  76. /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
  77. /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
  78. /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
  79. /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
  80. /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
  81. /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
  82. /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
  83. /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
  84. /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
  85. /package/{packages/sdk/dist → dist}/config.d.ts +0 -0
  86. /package/{packages/sdk/dist → dist}/config.js +0 -0
  87. /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
  88. /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
  89. /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
  90. /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
  91. /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
  92. /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
  93. /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
  94. /package/{packages/sdk/dist → dist}/crypto.js +0 -0
  95. /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
  96. /package/{packages/sdk/dist → dist}/event-client.js +0 -0
  97. /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
  98. /package/{packages/sdk/dist → dist}/events.js +0 -0
  99. /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
  100. /package/{packages/sdk/dist → dist}/explorer.js +0 -0
  101. /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
  102. /package/{packages/sdk/dist → dist}/keys.js +0 -0
  103. /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
  104. /package/{packages/sdk/dist → dist}/logger.js +0 -0
  105. /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
  106. /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
  107. /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
  108. /package/{packages/sdk/dist → dist}/merkle.js +0 -0
  109. /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
  110. /package/{packages/sdk/dist → dist}/note.js +0 -0
  111. /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
  112. /package/{packages/sdk/dist → dist}/pda.js +0 -0
  113. /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
  114. /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
  115. /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
  116. /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
  117. /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
  118. /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
  119. /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
  120. /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
  121. /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
  122. /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
  123. /package/{packages/sdk/dist → dist}/psbt.d.ts +0 -0
  124. /package/{packages/sdk/dist → dist}/psbt.js +0 -0
  125. /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
  126. /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
  127. /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
  128. /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
  129. /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
  130. /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
  131. /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
  132. /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
  133. /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
  134. /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
  135. /package/{packages/sdk/dist → dist}/spend-doc.d.ts +0 -0
  136. /package/{packages/sdk/dist → dist}/spend-doc.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
@@ -33,7 +33,7 @@ export declare const INSTRUCTION_DISCRIMINATORS: {
33
33
  readonly COMPLETE_DEPOSIT: 11;
34
34
  readonly SHIELD: 12;
35
35
  /** OP_RETURN-free deposit: note keys ride in instruction data, proven by the
36
- * deposit address's Taproot tweak. */
36
+ * deposit address's tapleaf. */
37
37
  readonly VERIFY_DEPOSIT: 25;
38
38
  readonly TRANSACT: 13;
39
39
  readonly UNSHIELD: 14;
@@ -762,16 +762,19 @@ export declare function buildCompleteDepositInstructionData(params: {
762
762
  *
763
763
  * The OP_RETURN-free deposit path. `notePublicKey` + `ephemeralPubkey` travel in
764
764
  * instruction data instead of in the Bitcoin transaction, and the program proves
765
- * them against the deposit output's Taproot tweak — a different key pair derives
766
- * a different address, which the funding transaction did not pay. Nothing marks
767
- * the deposit as a UTXOpia transaction on chain, so any wallet or exchange that
768
- * can send to a P2TR address can fund it.
765
+ * them against the deposit output's tapleaf — a different key pair derives a
766
+ * different leaf, and so a different address, which the funding transaction did
767
+ * not pay. Nothing marks the deposit as a UTXOpia transaction on chain, so any
768
+ * wallet or exchange that can send to a P2TR address can fund it.
769
769
  *
770
- * The address must be derived from `depositTweakCommitment(npk, eph)`, NOT from
771
- * the note key alone the program hashes both, so that a caller cannot swap in
772
- * an ephemeral key that leaves the note undiscoverable.
770
+ * Derive the address with `deriveDepositAddress(depositTweakCommitment(npk, eph),
771
+ * ikaXOnlyPubkey)`. Both keys are hashed into the leaf, so a caller cannot swap
772
+ * in an ephemeral key that leaves the note undiscoverable.
773
773
  *
774
- * Sweep mode only: `depositTxSize` must be non-zero. The receipt PDA is seeded
774
+ * No sweep: the deposit output's tapleaf names the pool's own dWallet key, so it
775
+ * is already under pool custody and is recorded as a pool UTXO directly. The
776
+ * SPV-verified transaction must therefore BE the deposit — `depositTxSize` is 0
777
+ * and `depositTxid` defaults to `sweepTxid`. The receipt PDA is seeded
775
778
  * `["deposit_receipt", txid, vout]`, so pass `depositVout` to
776
779
  * `deriveDepositReceiptPDA` for this flow.
777
780
  *
@@ -783,12 +786,26 @@ export declare function buildVerifyDepositInstructionData(params: {
783
786
  sweepTxid: Uint8Array;
784
787
  blockHeight: number;
785
788
  sweepTxSize: number;
786
- depositTxSize: number;
787
- depositTxid: Uint8Array;
789
+ depositTxSize?: number;
790
+ depositTxid?: Uint8Array;
788
791
  ephemeralPubkey: Uint8Array;
789
792
  notePublicKey: Uint8Array;
790
793
  depositVout: number;
791
794
  }): Uint8Array;
795
+ /**
796
+ * Build utxopia verify_deposit_permissioned instruction data (disc=26).
797
+ *
798
+ * `buildVerifyDepositInstructionData`'s payload with a different discriminator
799
+ * and an auditor ciphertext appended. The tapleaf already tells the pools apart —
800
+ * each carries its own Ika custody key — but that is not the same as clearing the
801
+ * pool's policy, which is what a permissioned pool exists for.
802
+ *
803
+ * The one-time PolicyApproval is bound to the WHOLE payload, ciphertext included,
804
+ * so these exact bytes must be the ones approved.
805
+ */
806
+ export declare function buildVerifyDepositPermissionedInstructionData(params: Parameters<typeof buildVerifyDepositInstructionData>[0] & {
807
+ auditorCiphertext?: Uint8Array;
808
+ }): Uint8Array;
792
809
  /** PoolConfig account discriminator (0x0a) */
793
810
  export declare const POOL_CONFIG_DISCRIMINATOR = 10;
794
811
  /** Serialized PoolConfig account length (bytes) */
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { AccountRole, } from "@solana/kit";
10
10
  import { sha256 } from "@noble/hashes/sha2.js";
11
+ import { bytesToHex as toHex } from "./crypto";
11
12
  import { address, getConfig, TOKEN_2022_PROGRAM_ID } from "./config";
12
13
  import { resolveAuditorCiphertext } from "./auditor-ciphertext";
13
14
  import { MAGICBLOCK_EPHEMERAL_VAULT_ID, MAGICBLOCK_DELEGATION_PROGRAM_ID, MAGICBLOCK_MAGIC_CONTEXT_ID, MAGICBLOCK_MAGIC_PROGRAM_ID, MAGICBLOCK_MAX_PER_MEMBERS, MAGICBLOCK_PERMISSION_PROGRAM_ID, MAGICBLOCK_PER_MEMBER_FLAGS, } from "./magicblock";
@@ -37,7 +38,7 @@ const INSTRUCTION = {
37
38
  COMPLETE_DEPOSIT: 11,
38
39
  SHIELD: 12,
39
40
  /** OP_RETURN-free deposit: note keys ride in instruction data, proven by the
40
- * deposit address's Taproot tweak. */
41
+ * deposit address's tapleaf. */
41
42
  VERIFY_DEPOSIT: 25,
42
43
  // JoinSplit (13-15) — all share n_in + n_out + n_pub + proof_source header
43
44
  TRANSACT: 13,
@@ -72,6 +73,8 @@ const INSTRUCTION = {
72
73
  const PERMISSIONED_DISC = {
73
74
  INITIALIZE_PERMISSIONED: 21,
74
75
  COMPLETE_DEPOSIT_PERMISSIONED: 22,
76
+ /** Same binding as VERIFY_DEPOSIT, plus the permissioned pool's policy gate. */
77
+ VERIFY_DEPOSIT_PERMISSIONED: 26,
75
78
  SHIELD_PERMISSIONED: 23,
76
79
  REGISTER_EXIT_DESTINATION: 39,
77
80
  };
@@ -1311,16 +1314,19 @@ export function buildCompleteDepositInstructionData(params) {
1311
1314
  *
1312
1315
  * The OP_RETURN-free deposit path. `notePublicKey` + `ephemeralPubkey` travel in
1313
1316
  * instruction data instead of in the Bitcoin transaction, and the program proves
1314
- * them against the deposit output's Taproot tweak — a different key pair derives
1315
- * a different address, which the funding transaction did not pay. Nothing marks
1316
- * the deposit as a UTXOpia transaction on chain, so any wallet or exchange that
1317
- * can send to a P2TR address can fund it.
1317
+ * them against the deposit output's tapleaf — a different key pair derives a
1318
+ * different leaf, and so a different address, which the funding transaction did
1319
+ * not pay. Nothing marks the deposit as a UTXOpia transaction on chain, so any
1320
+ * wallet or exchange that can send to a P2TR address can fund it.
1318
1321
  *
1319
- * The address must be derived from `depositTweakCommitment(npk, eph)`, NOT from
1320
- * the note key alone the program hashes both, so that a caller cannot swap in
1321
- * an ephemeral key that leaves the note undiscoverable.
1322
+ * Derive the address with `deriveDepositAddress(depositTweakCommitment(npk, eph),
1323
+ * ikaXOnlyPubkey)`. Both keys are hashed into the leaf, so a caller cannot swap
1324
+ * in an ephemeral key that leaves the note undiscoverable.
1322
1325
  *
1323
- * Sweep mode only: `depositTxSize` must be non-zero. The receipt PDA is seeded
1326
+ * No sweep: the deposit output's tapleaf names the pool's own dWallet key, so it
1327
+ * is already under pool custody and is recorded as a pool UTXO directly. The
1328
+ * SPV-verified transaction must therefore BE the deposit — `depositTxSize` is 0
1329
+ * and `depositTxid` defaults to `sweepTxid`. The receipt PDA is seeded
1324
1330
  * `["deposit_receipt", txid, vout]`, so pass `depositVout` to
1325
1331
  * `deriveDepositReceiptPDA` for this flow.
1326
1332
  *
@@ -1329,9 +1335,10 @@ export function buildCompleteDepositInstructionData(params) {
1329
1335
  * + note_public_key(32) + deposit_vout(u32 LE) = 149 bytes
1330
1336
  */
1331
1337
  export function buildVerifyDepositInstructionData(params) {
1338
+ const depositTxid = params.depositTxid ?? params.sweepTxid;
1332
1339
  for (const [name, value] of [
1333
1340
  ["sweepTxid", params.sweepTxid],
1334
- ["depositTxid", params.depositTxid],
1341
+ ["depositTxid", depositTxid],
1335
1342
  ["ephemeralPubkey", params.ephemeralPubkey],
1336
1343
  ["notePublicKey", params.notePublicKey],
1337
1344
  ]) {
@@ -1339,8 +1346,11 @@ export function buildVerifyDepositInstructionData(params) {
1339
1346
  throw new Error(`${name} must be 32 bytes, got ${value.length}`);
1340
1347
  }
1341
1348
  }
1342
- if (params.depositTxSize === 0) {
1343
- throw new Error("verify_deposit is sweep-mode only: depositTxSize must be non-zero");
1349
+ if (params.depositTxSize) {
1350
+ throw new Error("verify_deposit takes no second transaction: depositTxSize must be 0");
1351
+ }
1352
+ if (toHex(depositTxid) !== toHex(params.sweepTxid)) {
1353
+ throw new Error("verify_deposit proves the deposit itself: depositTxid must equal sweepTxid");
1344
1354
  }
1345
1355
  const data = new Uint8Array(149);
1346
1356
  const view = new DataView(data.buffer);
@@ -1352,9 +1362,9 @@ export function buildVerifyDepositInstructionData(params) {
1352
1362
  offset += 8;
1353
1363
  view.setUint32(offset, params.sweepTxSize, true);
1354
1364
  offset += 4;
1355
- view.setUint32(offset, params.depositTxSize, true);
1356
- offset += 4;
1357
- data.set(params.depositTxid, offset);
1365
+ view.setUint32(offset, 0, true);
1366
+ offset += 4; // no second transaction
1367
+ data.set(depositTxid, offset);
1358
1368
  offset += 32;
1359
1369
  data.set(params.ephemeralPubkey, offset);
1360
1370
  offset += 32;
@@ -1364,6 +1374,26 @@ export function buildVerifyDepositInstructionData(params) {
1364
1374
  offset += 4;
1365
1375
  return data;
1366
1376
  }
1377
+ /**
1378
+ * Build utxopia verify_deposit_permissioned instruction data (disc=26).
1379
+ *
1380
+ * `buildVerifyDepositInstructionData`'s payload with a different discriminator
1381
+ * and an auditor ciphertext appended. The tapleaf already tells the pools apart —
1382
+ * each carries its own Ika custody key — but that is not the same as clearing the
1383
+ * pool's policy, which is what a permissioned pool exists for.
1384
+ *
1385
+ * The one-time PolicyApproval is bound to the WHOLE payload, ciphertext included,
1386
+ * so these exact bytes must be the ones approved.
1387
+ */
1388
+ export function buildVerifyDepositPermissionedInstructionData(params) {
1389
+ const base = buildVerifyDepositInstructionData(params);
1390
+ const ciphertext = params.auditorCiphertext ?? new Uint8Array(0);
1391
+ const data = new Uint8Array(base.length + ciphertext.length);
1392
+ data.set(base, 0);
1393
+ data.set(ciphertext, base.length);
1394
+ data[0] = PERMISSIONED_DISC.VERIFY_DEPOSIT_PERMISSIONED;
1395
+ return data;
1396
+ }
1367
1397
  // =============================================================================
1368
1398
  // UTXOpia Set Pool Config (disc=2)
1369
1399
  // =============================================================================
@@ -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,86 @@ 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
+ };
203
283
  /** A deposit whose address alone binds the note keys — no OP_RETURN. */
204
284
  export interface TweakDepositResult {
205
285
  /** Taproot address to send BTC to */
@@ -210,8 +290,14 @@ export interface TweakDepositResult {
210
290
  npk: Uint8Array;
211
291
  /** 32-byte Ed25519 ephemeral public key */
212
292
  ephemeralPub: Uint8Array;
213
- /** sha256(npk || ephemeralPub) — the commitment the address is tweaked by */
293
+ /** sha256(npk || ephemeralPub) — the commitment carried in the tapleaf */
214
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;
215
301
  }
216
302
  /**
217
303
  * Create a deposit for the OP_RETURN-free flow (`verify_deposit`, disc 25).
@@ -219,15 +305,24 @@ export interface TweakDepositResult {
219
305
  * The transaction carries nothing but a payment, so anything that can send to a
220
306
  * P2TR address can fund it — a hardware wallet, an exchange withdrawal, a faucet.
221
307
  * The note keys are recovered from instruction data at completion time and proven
222
- * against this address's Taproot tweak, so substituting either key derives a
223
- * different address that the funding transaction never paid.
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.
224
320
  *
225
- * Sweep mode: the pool sweeps this address into its own custody, and that sweep
226
- * is what gets SPV-verified. Register the address with the tracker BEFORE any
227
- * coins are sent — a deposit with no OP_RETURN is invisible to block scanning,
228
- * so an unregistered address is one nobody is watching.
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.
229
324
  */
230
- export declare function createTweakDeposit(recipientMeta: StealthMetaAddress, vaultXOnlyPubkey: Uint8Array, network?: "mainnet" | "testnet" | "regtest"): Promise<TweakDepositResult>;
325
+ export declare function createTweakDeposit(recipientMeta: StealthMetaAddress, vaultXOnlyPubkey: Uint8Array, recovery: DepositRecoveryMaterial, network?: "mainnet" | "testnet" | "regtest"): Promise<TweakDepositResult>;
231
326
  /**
232
327
  * Create a non-interactive deposit using the current SDK config.
233
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,40 +254,166 @@ 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
+ }
251
371
  /**
252
372
  * Create a deposit for the OP_RETURN-free flow (`verify_deposit`, disc 25).
253
373
  *
254
374
  * The transaction carries nothing but a payment, so anything that can send to a
255
375
  * P2TR address can fund it — a hardware wallet, an exchange withdrawal, a faucet.
256
376
  * The note keys are recovered from instruction data at completion time and proven
257
- * against this address's Taproot tweak, so substituting either key derives a
258
- * different address that the funding transaction never paid.
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.
259
379
  *
260
- * Sweep mode: the pool sweeps this address into its own custody, and that sweep
261
- * is what gets SPV-verified. Register the address with the tracker BEFORE any
262
- * coins are sent a deposit with no OP_RETURN is invisible to block scanning,
263
- * so an unregistered address is one nobody is watching.
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.
264
393
  */
265
- export async function createTweakDeposit(recipientMeta, vaultXOnlyPubkey, network = "testnet") {
394
+ export async function createTweakDeposit(recipientMeta, vaultXOnlyPubkey, recovery, network = "testnet") {
266
395
  if (vaultXOnlyPubkey.length !== 32) {
267
396
  throw new Error("vaultXOnlyPubkey must be 32 bytes");
268
397
  }
269
398
  const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
270
- const ephemeral = ed25519GenerateKeyPair();
399
+ const ephemeral = depositEphemeralKeyPair(recovery);
271
400
  const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
272
401
  const stealthScalar = deriveStealthScalar(sharedSecret);
273
402
  const recipientMPK = bytesToBigint(recipientMeta.mpk);
274
403
  const npk = bigintToBytes(computeNPKSync(recipientMPK, stealthScalar));
275
404
  const ephemeralPub = new Uint8Array(ephemeral.pubKey);
276
- const { depositTweakCommitment, deriveTaprootAddress } = await import("./taproot");
405
+ const { depositTweakCommitment, deriveDepositAddress } = await import("./taproot");
277
406
  const tweakCommitment = depositTweakCommitment(npk, ephemeralPub);
278
- const { address, outputKey } = deriveTaprootAddress(tweakCommitment, network, vaultXOnlyPubkey);
407
+ const { address, outputKey, leafScript, leafHash, controlBlock } = deriveDepositAddress(tweakCommitment, vaultXOnlyPubkey, network);
279
408
  return {
280
409
  btcAddress: address,
281
410
  depositOutputKey: outputKey,
282
411
  npk,
283
412
  ephemeralPub,
284
413
  tweakCommitment,
414
+ leafScript,
415
+ leafHash,
416
+ controlBlock,
285
417
  };
286
418
  }
287
419
  /**
@@ -22,18 +22,55 @@ 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;
25
27
  };
26
28
  /**
27
- * Commitment a `verify_deposit` (disc 25) deposit address is derived from.
29
+ * Per-deposit commitment carried in a `verify_deposit` (disc 25) address's tapleaf.
28
30
  *
29
31
  * Both keys are hashed in. Binding the note key alone would leave the ephemeral
30
32
  * pubkey caller-chosen on the Solana side: the credited amount and owner would
31
33
  * still be right, but a substituted ephemeral key makes the stealth announcement
32
34
  * undecryptable and the recipient never finds their note.
33
35
  *
34
- * Feed the result to `deriveTaprootAddress` as the commitment.
36
+ * Feed the result to `deriveDepositAddress`.
35
37
  */
36
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;
73
+ };
37
74
  /**
38
75
  * Verify that a Taproot address is correctly derived from a commitment
39
76
  *