@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
@@ -0,0 +1,2820 @@
1
+ /**
2
+ * UTXOPIA Instruction Builders (JoinSplit Architecture)
3
+ *
4
+ * Low-level instruction building for UTXOPIA operations.
5
+ * All Groth16 proofs are verified inline using BN254 pairing syscalls.
6
+ *
7
+ * @module instructions
8
+ */
9
+
10
+ import {
11
+ AccountRole,
12
+ type Address,
13
+ } from "@solana/kit";
14
+ import { sha256 } from "@noble/hashes/sha2.js";
15
+ import { bytesToHex as toHex } from "./crypto";
16
+
17
+ import { address, getConfig, TOKEN_2022_PROGRAM_ID } from "./config";
18
+ import { type AuditorCiphertextInput, resolveAuditorCiphertext } from "./auditor-ciphertext";
19
+ import {
20
+ MAGICBLOCK_EPHEMERAL_VAULT_ID,
21
+ MAGICBLOCK_DELEGATION_PROGRAM_ID,
22
+ MAGICBLOCK_MAGIC_CONTEXT_ID,
23
+ MAGICBLOCK_MAGIC_PROGRAM_ID,
24
+ MAGICBLOCK_MAX_PER_MEMBERS,
25
+ MAGICBLOCK_PERMISSION_PROGRAM_ID,
26
+ MAGICBLOCK_PER_MEMBER_FLAGS,
27
+ } from "./magicblock";
28
+
29
+ /** System program address */
30
+ const SYSTEM_PROGRAM_ADDRESS = address("11111111111111111111111111111111");
31
+
32
+ // =============================================================================
33
+ // Types
34
+ // =============================================================================
35
+
36
+ /** Instruction type for v2 */
37
+ export interface Instruction {
38
+ programAddress: Address;
39
+ accounts: Array<{ address: Address; role: (typeof AccountRole)[keyof typeof AccountRole] }>;
40
+ data: Uint8Array;
41
+ }
42
+
43
+ // =============================================================================
44
+ // Constants
45
+ // =============================================================================
46
+
47
+ /** Instruction discriminators — sequential 0-19 (must match contracts/programs/utxopia/src/lib.rs) */
48
+ const INSTRUCTION = {
49
+ // Core (0-2)
50
+ INITIALIZE: 0,
51
+ SET_PAUSED: 1,
52
+ SET_POOL_CONFIG: 2,
53
+ // Pool updates (3-5)
54
+ PROPOSE_POOL_UPDATE: 3,
55
+ EXECUTE_POOL_UPDATE: 4,
56
+ CANCEL_POOL_UPDATE: 5,
57
+ // VK admin (6-7)
58
+ INIT_VK_REGISTRY: 6,
59
+ UPDATE_VK_REGISTRY: 7,
60
+ // Multi-token (8-10)
61
+ REGISTER_TOKEN: 8,
62
+ UPDATE_TOKEN_CONFIG: 9,
63
+ CLAIM_FEES: 10,
64
+ // Deposit (11-12, 25)
65
+ COMPLETE_DEPOSIT: 11,
66
+ SHIELD: 12,
67
+ /** OP_RETURN-free deposit: note keys ride in instruction data, proven by the
68
+ * deposit address's tapleaf. */
69
+ VERIFY_DEPOSIT: 25,
70
+ // JoinSplit (13-15) — all share n_in + n_out + n_pub + proof_source header
71
+ TRANSACT: 13,
72
+ UNSHIELD: 14,
73
+ REDEEM: 15,
74
+ // VK registry freeze (16) — NOT part of the redemption range below
75
+ FREEZE_VK_REGISTRY: 16,
76
+ // Redemption lifecycle (17-19)
77
+ COMPLETE_REDEMPTION: 17,
78
+ MARK_PROCESSING: 18,
79
+ CANCEL_REDEMPTION: 19,
80
+ // Tree management (20)
81
+ ROTATE_TREE: 20,
82
+ // 21-23 are permissioned-pool ops — see PERMISSIONED_DISC
83
+ APPROVE_REDEMPTION_SIGNING: 27,
84
+ // Auditor-only setters (28-29) — utxopia program, permissioned pools
85
+ SET_AUDITOR_FROZEN: 28,
86
+ SET_AUDITOR_VIEWING_PUBKEY: 29,
87
+ // MagicBlock ER/PER lifecycle helpers (32-33)
88
+ MAGICBLOCK_DELEGATE: 32,
89
+ MAGICBLOCK_COMMIT: 33,
90
+ MAGICBLOCK_PER_PERMISSION: 34,
91
+ ROTATE_AUDITOR: 35,
92
+ INITIALIZE_POLICY_APPROVAL: 36,
93
+ POLICY_APPROVAL_DECISION: 37,
94
+ POLICY_APPROVAL_COMMIT: 38,
95
+ } as const;
96
+
97
+ /**
98
+ * Discriminants for permissioned-pool instructions (utxopia program only).
99
+ * Values must match programs/utxopia/src/lib.rs exactly.
100
+ */
101
+ const PERMISSIONED_DISC = {
102
+ INITIALIZE_PERMISSIONED: 21,
103
+ COMPLETE_DEPOSIT_PERMISSIONED: 22,
104
+ /** Same binding as VERIFY_DEPOSIT, plus the permissioned pool's policy gate. */
105
+ VERIFY_DEPOSIT_PERMISSIONED: 26,
106
+ SHIELD_PERMISSIONED: 23,
107
+ REGISTER_EXIT_DESTINATION: 39,
108
+ } as const;
109
+
110
+ /** Export instruction discriminators for consumers */
111
+ export const INSTRUCTION_DISCRIMINATORS = INSTRUCTION;
112
+
113
+ // =============================================================================
114
+ // Utilities
115
+ // =============================================================================
116
+
117
+ /**
118
+ * Simple base58 decoding for addresses
119
+ */
120
+ function bs58Decode(str: string): Uint8Array {
121
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
122
+ const ALPHABET_MAP = new Map<string, number>();
123
+ for (let i = 0; i < ALPHABET.length; i++) {
124
+ ALPHABET_MAP.set(ALPHABET[i], i);
125
+ }
126
+
127
+ let num = BigInt(0);
128
+ for (const char of str) {
129
+ const val = ALPHABET_MAP.get(char);
130
+ if (val === undefined) {
131
+ throw new Error(`Invalid base58 character: ${char}`);
132
+ }
133
+ num = num * BigInt(58) + BigInt(val);
134
+ }
135
+
136
+ // Count leading zeros
137
+ let leadingZeros = 0;
138
+ for (const char of str) {
139
+ if (char === "1") {
140
+ leadingZeros++;
141
+ } else {
142
+ break;
143
+ }
144
+ }
145
+
146
+ // Convert to bytes
147
+ const bytes: number[] = [];
148
+ while (num > BigInt(0)) {
149
+ bytes.unshift(Number(num % BigInt(256)));
150
+ num = num / BigInt(256);
151
+ }
152
+
153
+ // Add leading zeros
154
+ for (let i = 0; i < leadingZeros; i++) {
155
+ bytes.unshift(0);
156
+ }
157
+
158
+ // Ensure 32 bytes for Solana addresses
159
+ while (bytes.length < 32) {
160
+ bytes.unshift(0);
161
+ }
162
+
163
+ return new Uint8Array(bytes);
164
+ }
165
+
166
+ /**
167
+ * Convert Address to bytes
168
+ */
169
+ function addressToBytes(addr: Address): Uint8Array {
170
+ return bs58Decode(addr.toString());
171
+ }
172
+
173
+ const STEALTH_DATA_PER_OUTPUT = 72; // ephemeral_pub(32) + encrypted_amount(8) + encrypted_token_id(32)
174
+
175
+ function assertStealthDataRecordLengths(stealthData: Uint8Array[]): void {
176
+ for (let i = 0; i < stealthData.length; i++) {
177
+ if (stealthData[i].length !== STEALTH_DATA_PER_OUTPUT) {
178
+ throw new Error(`Stealth data ${i} must be ${STEALTH_DATA_PER_OUTPUT} bytes, got ${stealthData[i].length}`);
179
+ }
180
+ }
181
+ }
182
+
183
+ // =============================================================================
184
+ // Shield Instruction Builder (disc=12)
185
+ // =============================================================================
186
+
187
+ /** Shield instruction options */
188
+ export interface ShieldInstructionOptions {
189
+ /** Amount to shield (in token's smallest unit — lamports, micro-USDC, sats) */
190
+ amount: bigint;
191
+ /** NPK bytes (32) — recipient's note public key */
192
+ npk: Uint8Array;
193
+ /** Ephemeral public key (32) — for stealth address derivation */
194
+ ephemeralPub: Uint8Array;
195
+ /** Accounts required for the shield instruction */
196
+ accounts: {
197
+ user: Address;
198
+ userTokenAccount: Address;
199
+ poolState: Address;
200
+ tokenConfig: Address;
201
+ vault: Address;
202
+ commitmentTree: Address;
203
+ tokenProgram: Address;
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Build shield instruction data (disc=12).
209
+ *
210
+ * Layout (after disc stripped by entrypoint):
211
+ * - amount: u64 LE (8 bytes)
212
+ * - npk: [u8; 32]
213
+ * - ephemeral_pub: [u8; 32]
214
+ */
215
+ export function buildShieldInstructionData(options: {
216
+ amount: bigint;
217
+ npk: Uint8Array;
218
+ ephemeralPub: Uint8Array;
219
+ }): Uint8Array {
220
+ const data = new Uint8Array(73);
221
+ data[0] = INSTRUCTION.SHIELD;
222
+ const view = new DataView(data.buffer);
223
+ view.setBigUint64(1, options.amount, true);
224
+ data.set(options.npk.slice(0, 32), 9);
225
+ data.set(options.ephemeralPub.slice(0, 32), 41);
226
+ return data;
227
+ }
228
+
229
+ /**
230
+ * Build a complete shield instruction (disc=12).
231
+ *
232
+ * Shields SPL tokens into the privacy pool. Works with both
233
+ * legacy Token program (wSOL) and Token-2022 (USDC, USDT, etc.).
234
+ */
235
+ export function buildShieldInstruction(options: ShieldInstructionOptions): Instruction {
236
+ const config = getConfig();
237
+ const data = buildShieldInstructionData({
238
+ amount: options.amount,
239
+ npk: options.npk,
240
+ ephemeralPub: options.ephemeralPub,
241
+ });
242
+
243
+ return {
244
+ programAddress: config.utxopiaProgramId,
245
+ accounts: [
246
+ { address: options.accounts.user, role: AccountRole.WRITABLE_SIGNER },
247
+ { address: options.accounts.userTokenAccount, role: AccountRole.WRITABLE },
248
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
249
+ { address: options.accounts.tokenConfig, role: AccountRole.WRITABLE },
250
+ { address: options.accounts.vault, role: AccountRole.WRITABLE },
251
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
252
+ { address: options.accounts.tokenProgram, role: AccountRole.READONLY },
253
+ ],
254
+ data,
255
+ };
256
+ }
257
+
258
+ // =============================================================================
259
+ // Complete Redemption Instruction Builder
260
+ // =============================================================================
261
+
262
+ /** Complete redemption instruction options */
263
+ export interface CompleteRedemptionInstructionOptions {
264
+ /** BTC transaction ID (internal byte order, 32 bytes) */
265
+ btcTxid: Uint8Array;
266
+ /** Raw tx size in ChadBuffer */
267
+ txSize: number;
268
+ /** Pool scriptPubKey for change UTXO tracking (empty = no tracking) */
269
+ poolScript: Uint8Array;
270
+ /** Number of consumed UTXO PDAs in remaining accounts */
271
+ consumedUtxoCount: number;
272
+ /** Account addresses */
273
+ accounts: {
274
+ poolState: Address;
275
+ redemptionRequest: Address;
276
+ authority: Address;
277
+ rentRecipient: Address;
278
+ verifiedTransaction: Address;
279
+ lightClient: Address;
280
+ txBuffer: Address;
281
+ zkbtcMint: Address;
282
+ poolVault: Address;
283
+ completionReceipt: Address;
284
+ poolConfig: Address;
285
+ /** HeightIndex PDA for the VerifiedTransaction's block —
286
+ * `deriveHeightIndexPDA(blockHeight, config.btcLightClientProgramId)`.
287
+ *
288
+ * REQUIRED. The program re-checks that the proof's block is still the canonical one at
289
+ * that height before it settles (audit_1 F-BTC-04): a VerifiedTransaction records a merkle
290
+ * proof that was valid once and is never invalidated, and the confirmation count is taken
291
+ * against a tip that only grows, so neither notices a reorg. Omitting this fails with
292
+ * InvalidSpvProof — the program locates the account by address, so its position in the
293
+ * list does not matter, but its absence is an error rather than a skipped check.
294
+ */
295
+ heightIndex: Address;
296
+ /** Change UTXO PDA. Required when poolScript is non-empty. */
297
+ changeUtxo?: Address;
298
+ /** zkBTC TokenConfig PDA (credits protocol revenue) */
299
+ tokenConfig: Address;
300
+ /** Token program for zkBTC mint (TOKEN_2022_PROGRAM_ID or TOKEN_PROGRAM_ID). Defaults to Token-2022. */
301
+ tokenProgram?: Address;
302
+ /** Consumed UTXO PDAs to close */
303
+ consumedUtxos?: Address[];
304
+ };
305
+ }
306
+
307
+ export interface ApproveRedemptionSigningInstructionOptions {
308
+ /** BIP-341 taproot key-spend sighash for the unsigned BTC transaction. */
309
+ btcSighash: Uint8Array;
310
+ /** Optional keccak256(Sign.message), where Sign.message is the TapSighash preimage. */
311
+ ikaMessageDigest?: Uint8Array;
312
+ /** Miner fee in satoshis, checked by the on-chain signing policy. */
313
+ minerFeeSats: bigint | number;
314
+ accounts: {
315
+ poolState: Address;
316
+ redemptionRequest: Address;
317
+ authority: Address;
318
+ poolConfig: Address;
319
+ /** HeightIndex PDA for the VerifiedTransaction's block —
320
+ * `deriveHeightIndexPDA(blockHeight, config.btcLightClientProgramId)`.
321
+ *
322
+ * REQUIRED. The program re-checks that the proof's block is still the canonical one at
323
+ * that height before it settles (audit_1 F-BTC-04): a VerifiedTransaction records a merkle
324
+ * proof that was valid once and is never invalidated, and the confirmation count is taken
325
+ * against a tip that only grows, so neither notices a reorg. Omitting this fails with
326
+ * InvalidSpvProof — the program locates the account by address, so its position in the
327
+ * list does not matter, but its absence is an error rather than a skipped check.
328
+ */
329
+ heightIndex: Address;
330
+ ikaProgram: Address;
331
+ ikaCoordinator: Address;
332
+ ikaMessageApproval: Address;
333
+ ikaDwallet: Address;
334
+ callerProgram: Address;
335
+ cpiAuthority: Address;
336
+ ikaPayer: Address;
337
+ };
338
+ }
339
+
340
+ export function buildApproveRedemptionSigningInstructionData(options: {
341
+ btcSighash: Uint8Array;
342
+ ikaMessageDigest?: Uint8Array;
343
+ minerFeeSats: bigint | number;
344
+ }): Uint8Array {
345
+ if (options.btcSighash.length !== 32) {
346
+ throw new Error("btcSighash must be exactly 32 bytes");
347
+ }
348
+ if (options.ikaMessageDigest && options.ikaMessageDigest.length !== 32) {
349
+ throw new Error("ikaMessageDigest must be exactly 32 bytes");
350
+ }
351
+ const data = new Uint8Array(1 + 32 + (options.ikaMessageDigest ? 32 : 0) + 8);
352
+ const view = new DataView(data.buffer);
353
+ let offset = 0;
354
+ data[offset++] = INSTRUCTION.APPROVE_REDEMPTION_SIGNING;
355
+ data.set(options.btcSighash, offset); offset += 32;
356
+ if (options.ikaMessageDigest) {
357
+ data.set(options.ikaMessageDigest, offset); offset += 32;
358
+ }
359
+ view.setBigUint64(offset, BigInt(options.minerFeeSats), true);
360
+ return data;
361
+ }
362
+
363
+ export function buildApproveRedemptionSigningInstruction(
364
+ options: ApproveRedemptionSigningInstructionOptions
365
+ ): Instruction {
366
+ const config = getConfig();
367
+ return {
368
+ programAddress: config.utxopiaProgramId,
369
+ accounts: [
370
+ { address: options.accounts.poolState, role: AccountRole.READONLY },
371
+ { address: options.accounts.redemptionRequest, role: AccountRole.READONLY },
372
+ { address: options.accounts.authority, role: AccountRole.READONLY_SIGNER },
373
+ { address: options.accounts.poolConfig, role: AccountRole.READONLY },
374
+ { address: options.accounts.ikaProgram, role: AccountRole.READONLY },
375
+ { address: options.accounts.ikaCoordinator, role: AccountRole.READONLY },
376
+ { address: options.accounts.ikaMessageApproval, role: AccountRole.WRITABLE },
377
+ { address: options.accounts.ikaDwallet, role: AccountRole.READONLY },
378
+ { address: options.accounts.callerProgram, role: AccountRole.READONLY },
379
+ { address: options.accounts.cpiAuthority, role: AccountRole.READONLY },
380
+ { address: options.accounts.ikaPayer, role: AccountRole.WRITABLE_SIGNER },
381
+ { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
382
+ ],
383
+ data: buildApproveRedemptionSigningInstructionData({
384
+ btcSighash: options.btcSighash,
385
+ ikaMessageDigest: options.ikaMessageDigest,
386
+ minerFeeSats: options.minerFeeSats,
387
+ }),
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Build instruction data for COMPLETE_REDEMPTION (disc 17)
393
+ *
394
+ * Layout (after disc stripped):
395
+ * - btc_txid: [u8; 32]
396
+ * - tx_size: u32 LE
397
+ * - pool_script_len: u8
398
+ * - pool_script: [u8; 0-34]
399
+ * - consumed_utxo_count: u8
400
+ */
401
+ export function buildCompleteRedemptionInstructionData(options: {
402
+ btcTxid: Uint8Array;
403
+ txSize: number;
404
+ poolScript: Uint8Array;
405
+ consumedUtxoCount: number;
406
+ }): Uint8Array {
407
+ const { btcTxid, txSize, poolScript, consumedUtxoCount } = options;
408
+
409
+ const totalLen = 1 + 32 + 4 + 1 + poolScript.length + 1;
410
+ const data = new Uint8Array(totalLen);
411
+ const view = new DataView(data.buffer);
412
+
413
+ let offset = 0;
414
+ data[offset++] = INSTRUCTION.COMPLETE_REDEMPTION;
415
+
416
+ data.set(btcTxid, offset); offset += 32;
417
+ view.setUint32(offset, txSize, true); offset += 4;
418
+ data[offset++] = poolScript.length;
419
+ if (poolScript.length > 0) {
420
+ data.set(poolScript, offset); offset += poolScript.length;
421
+ }
422
+ data[offset++] = consumedUtxoCount;
423
+
424
+ return data;
425
+ }
426
+
427
+ /**
428
+ * Build a complete redemption instruction
429
+ *
430
+ * Accounts (14 base + optional change + variable consumed UTXOs):
431
+ * 0. pool_state (writable)
432
+ * 1. redemption_request (writable)
433
+ * 2. authority (signer)
434
+ * 3. rent_recipient (readonly)
435
+ * 4. verified_transaction (readonly)
436
+ * 5. light_client (readonly)
437
+ * 6. tx_buffer (readonly)
438
+ * 7. zkbtc_mint (writable)
439
+ * 8. pool_vault (writable)
440
+ * 9. token_program (readonly)
441
+ * 10. completion_receipt (writable)
442
+ * 11. system_program (readonly)
443
+ * 12. pool_config (readonly)
444
+ * 13. change_utxo (writable, only when pool_script is non-empty)
445
+ * 13/14..+N consumed_utxos (writable)
446
+ * final. token_config (writable)
447
+ */
448
+ export function buildCompleteRedemptionInstruction(
449
+ options: CompleteRedemptionInstructionOptions
450
+ ): Instruction {
451
+ const config = getConfig();
452
+
453
+ const data = buildCompleteRedemptionInstructionData({
454
+ btcTxid: options.btcTxid,
455
+ txSize: options.txSize,
456
+ poolScript: options.poolScript,
457
+ consumedUtxoCount: options.consumedUtxoCount,
458
+ });
459
+
460
+ const accounts: Instruction["accounts"] = [
461
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
462
+ { address: options.accounts.redemptionRequest, role: AccountRole.WRITABLE },
463
+ { address: options.accounts.authority, role: AccountRole.WRITABLE_SIGNER },
464
+ { address: options.accounts.rentRecipient, role: AccountRole.READONLY },
465
+ { address: options.accounts.verifiedTransaction, role: AccountRole.READONLY },
466
+ { address: options.accounts.lightClient, role: AccountRole.READONLY },
467
+ { address: options.accounts.txBuffer, role: AccountRole.READONLY },
468
+ { address: options.accounts.zkbtcMint, role: AccountRole.WRITABLE },
469
+ { address: options.accounts.poolVault, role: AccountRole.WRITABLE },
470
+ { address: options.accounts.tokenProgram ?? TOKEN_2022_PROGRAM_ID, role: AccountRole.READONLY },
471
+ { address: options.accounts.completionReceipt, role: AccountRole.WRITABLE },
472
+ { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
473
+ { address: options.accounts.poolConfig, role: AccountRole.READONLY },
474
+ ];
475
+
476
+ if (options.poolScript.length > 0) {
477
+ if (!options.accounts.changeUtxo) {
478
+ throw new Error("changeUtxo is required when poolScript is non-empty");
479
+ }
480
+ accounts.push({ address: options.accounts.changeUtxo, role: AccountRole.WRITABLE });
481
+ }
482
+
483
+ // Append consumed UTXO PDAs
484
+ if (options.accounts.consumedUtxos) {
485
+ for (const utxo of options.accounts.consumedUtxos) {
486
+ accounts.push({ address: utxo, role: AccountRole.WRITABLE });
487
+ }
488
+ }
489
+
490
+ accounts.push({ address: options.accounts.tokenConfig, role: AccountRole.WRITABLE });
491
+ // Located by address, so the trailing position is free — this instruction already has a
492
+ // variable tail (change UTXO, consumed UTXOs) and the program scans rather than indexing.
493
+ accounts.push({ address: options.accounts.heightIndex, role: AccountRole.READONLY });
494
+
495
+ return {
496
+ programAddress: config.utxopiaProgramId,
497
+ accounts,
498
+ data,
499
+ };
500
+ }
501
+
502
+ // =============================================================================
503
+ // Cancel Redemption Instruction Builder
504
+ // =============================================================================
505
+
506
+ /** Cancel redemption instruction options */
507
+ export interface CancelRedemptionInstructionOptions {
508
+ /** Note public key for the re-minted commitment (32 bytes) */
509
+ npk: Uint8Array;
510
+ accounts: {
511
+ /** Original requester (signer; receives the closed request's rent) */
512
+ user: Address;
513
+ poolState: Address;
514
+ redemptionRequest: Address;
515
+ commitmentTree: Address;
516
+ tokenConfig: Address;
517
+ /**
518
+ * Reserved UtxoRecord PDAs to release. Required when the request was in
519
+ * Processing (mark_processing reserved these UTXOs); pass [] / omit for a
520
+ * Pending cancel, which never reserved any. The program rejects a mismatch.
521
+ */
522
+ reservedUtxos?: Address[];
523
+ };
524
+ }
525
+
526
+ /**
527
+ * Build cancel redemption instruction data.
528
+ *
529
+ * Layout (after disc stripped by entrypoint):
530
+ * - npk: [u8; 32]
531
+ * - utxo_count: u8 (number of reserved UtxoRecord accounts that follow; 0 for Pending)
532
+ */
533
+ export function buildCancelRedemptionInstructionData(options: {
534
+ npk: Uint8Array;
535
+ reservedUtxoCount: number;
536
+ }): Uint8Array {
537
+ const { npk, reservedUtxoCount } = options;
538
+ if (npk.length !== 32) {
539
+ throw new Error("npk must be 32 bytes");
540
+ }
541
+ // disc(1) + npk(32) + utxo_count(1)
542
+ const data = new Uint8Array(1 + 32 + 1);
543
+ let offset = 0;
544
+ data[offset++] = INSTRUCTION.CANCEL_REDEMPTION;
545
+ data.set(npk, offset);
546
+ offset += 32;
547
+ data[offset++] = reservedUtxoCount;
548
+ return data;
549
+ }
550
+
551
+ /**
552
+ * Build a cancel redemption instruction.
553
+ *
554
+ * Accounts (6 base + variable):
555
+ * 0. user (writable signer)
556
+ * 1. pool_state (writable)
557
+ * 2. redemption_request (writable)
558
+ * 3. commitment_tree (writable)
559
+ * 4. system_program (readonly)
560
+ * 5. token_config (writable)
561
+ * 6..6+N reserved UtxoRecord PDAs (writable) — Processing cancels only
562
+ */
563
+ export function buildCancelRedemptionInstruction(
564
+ options: CancelRedemptionInstructionOptions
565
+ ): Instruction {
566
+ const config = getConfig();
567
+ const reservedUtxos = options.accounts.reservedUtxos ?? [];
568
+
569
+ const data = buildCancelRedemptionInstructionData({
570
+ npk: options.npk,
571
+ reservedUtxoCount: reservedUtxos.length,
572
+ });
573
+
574
+ const accounts: Instruction["accounts"] = [
575
+ { address: options.accounts.user, role: AccountRole.WRITABLE_SIGNER },
576
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
577
+ { address: options.accounts.redemptionRequest, role: AccountRole.WRITABLE },
578
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
579
+ { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
580
+ { address: options.accounts.tokenConfig, role: AccountRole.WRITABLE },
581
+ ];
582
+
583
+ for (const utxo of reservedUtxos) {
584
+ accounts.push({ address: utxo, role: AccountRole.WRITABLE });
585
+ }
586
+
587
+ return {
588
+ programAddress: config.utxopiaProgramId,
589
+ accounts,
590
+ data,
591
+ };
592
+ }
593
+
594
+ // =============================================================================
595
+ // JoinSplit Transact Instruction Builder
596
+ // =============================================================================
597
+
598
+ /** JoinSplit transact instruction options */
599
+ export interface TransactInstructionOptions {
600
+ /** Number of input notes being spent */
601
+ nInputs: number;
602
+ /** Number of output notes being created */
603
+ nOutputs: number;
604
+ /** Groth16 proof bytes (256 bytes) */
605
+ proofBytes: Uint8Array;
606
+ /** Merkle root */
607
+ merkleRoot: Uint8Array;
608
+ /** Bound parameters hash */
609
+ boundParamsHash: Uint8Array;
610
+ /** Nullifiers (32 bytes each) */
611
+ nullifiers: Uint8Array[];
612
+ /** Output commitments (32 bytes each) */
613
+ commitmentsOut: Uint8Array[];
614
+ /** Per-output stealth data: ephemeral_pub (32) + encrypted_amount (8) */
615
+ stealthData: Uint8Array[];
616
+ /** Reserved. Sender memos are rejected until they are proof-bound. */
617
+ senderMemos?: Uint8Array[];
618
+ /** Account addresses */
619
+ accounts: {
620
+ poolState: Address;
621
+ commitmentTree: Address;
622
+ vkRegistry: Address;
623
+ user: Address;
624
+ /** Nullifier record PDAs (one per input) */
625
+ nullifierRecords: Address[];
626
+ /** Required when poolState is permissioned. Appended before any proof buffer. */
627
+ policyApproval?: Address;
628
+ };
629
+ }
630
+
631
+ /**
632
+ * Build transact instruction data (JoinSplit)
633
+ *
634
+ * Layout (after disc stripped by entrypoint):
635
+ * - n_inputs: u8
636
+ * - n_outputs: u8
637
+ * - n_public_outputs: u8 (always 0 for transact)
638
+ * - proof_source: u8 (0=inline, 1=buffer account)
639
+ * - proof: [u8; 256] (only if proof_source=0)
640
+ * - merkle_root: [u8; 32]
641
+ * - bound_params_hash: [u8; 32]
642
+ * - nullifiers: [[u8; 32]; n_inputs]
643
+ * - commitments_out: [[u8; 32]; n_outputs]
644
+ * - stealth_data: [ephemeral_pub(32) + encrypted_amount(8) + encrypted_token_id(32)] x n_outputs
645
+ */
646
+ export function buildTransactInstructionData(options: {
647
+ nInputs: number;
648
+ nOutputs: number;
649
+ /** Groth16 proof (256 bytes). Omit when using buffer mode. */
650
+ proofBytes?: Uint8Array;
651
+ merkleRoot: Uint8Array;
652
+ boundParamsHash: Uint8Array;
653
+ nullifiers: Uint8Array[];
654
+ commitmentsOut: Uint8Array[];
655
+ stealthData: Uint8Array[];
656
+ /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
657
+ proofSource?: 0 | 1;
658
+ /** Reserved. Sender memos are rejected until they are proof-bound. */
659
+ senderMemos?: Uint8Array[];
660
+ }): Uint8Array {
661
+ const { nInputs, nOutputs, proofBytes, merkleRoot, boundParamsHash, nullifiers, commitmentsOut, stealthData, senderMemos } = options;
662
+ const proofSource = options.proofSource ?? 0;
663
+
664
+ if (proofSource === 0 && (!proofBytes || proofBytes.length !== 256)) {
665
+ throw new Error(`Inline mode requires 256-byte proof, got ${proofBytes?.length ?? 0}`);
666
+ }
667
+ if (nullifiers.length !== nInputs) {
668
+ throw new Error(`Expected ${nInputs} nullifiers, got ${nullifiers.length}`);
669
+ }
670
+ if (commitmentsOut.length !== nOutputs) {
671
+ throw new Error(`Expected ${nOutputs} commitments, got ${commitmentsOut.length}`);
672
+ }
673
+ if (stealthData.length !== nOutputs) {
674
+ throw new Error(`Expected ${nOutputs} stealth data entries, got ${stealthData.length}`);
675
+ }
676
+ assertStealthDataRecordLengths(stealthData);
677
+
678
+ if (senderMemos != null) {
679
+ throw new Error("senderMemos are disabled until a proof-bound protocol version is available");
680
+ }
681
+
682
+ const proofSize = proofSource === 0 ? 256 : 0;
683
+ const totalSize =
684
+ 1 + 4 + proofSize + 32 + 32 + nInputs * 32 + nOutputs * 32 + nOutputs * STEALTH_DATA_PER_OUTPUT;
685
+ const data = new Uint8Array(totalSize);
686
+
687
+ let offset = 0;
688
+
689
+ // Discriminator
690
+ data[offset++] = INSTRUCTION.TRANSACT;
691
+
692
+ // Header (4 bytes)
693
+ data[offset++] = nInputs;
694
+ data[offset++] = nOutputs;
695
+ data[offset++] = 0; // n_public_outputs = 0 for transact
696
+ data[offset++] = proofSource;
697
+
698
+ // Proof (256 bytes, only in inline mode)
699
+ if (proofSource === 0 && proofBytes) {
700
+ data.set(proofBytes, offset);
701
+ offset += 256;
702
+ }
703
+
704
+ // Merkle root (32 bytes)
705
+ data.set(merkleRoot, offset);
706
+ offset += 32;
707
+
708
+ // Bound params hash (32 bytes)
709
+ data.set(boundParamsHash, offset);
710
+ offset += 32;
711
+
712
+ // Nullifiers
713
+ for (const nullifier of nullifiers) {
714
+ data.set(nullifier, offset);
715
+ offset += 32;
716
+ }
717
+
718
+ // Output commitments
719
+ for (const commitment of commitmentsOut) {
720
+ data.set(commitment, offset);
721
+ offset += 32;
722
+ }
723
+
724
+ // Stealth data (ephemeral_pub + encrypted_amount per output)
725
+ for (const sd of stealthData) {
726
+ data.set(sd, offset);
727
+ offset += STEALTH_DATA_PER_OUTPUT;
728
+ }
729
+
730
+ return data;
731
+ }
732
+
733
+ /**
734
+ * Build a complete JoinSplit transact instruction
735
+ *
736
+ * Accounts:
737
+ * 0. pool_state (writable)
738
+ * 1. commitment_tree (writable)
739
+ * 2. vk_registry (read)
740
+ * 3. user (signer)
741
+ * 4. system_program (read)
742
+ * 5..5+N nullifier_records (writable)
743
+ */
744
+ export function buildTransactInstruction(options: TransactInstructionOptions): Instruction {
745
+ const config = getConfig();
746
+
747
+ const data = buildTransactInstructionData({
748
+ nInputs: options.nInputs,
749
+ nOutputs: options.nOutputs,
750
+ proofBytes: options.proofBytes,
751
+ merkleRoot: options.merkleRoot,
752
+ boundParamsHash: options.boundParamsHash,
753
+ nullifiers: options.nullifiers,
754
+ commitmentsOut: options.commitmentsOut,
755
+ stealthData: options.stealthData,
756
+ senderMemos: options.senderMemos,
757
+ });
758
+
759
+ const accounts: Instruction["accounts"] = [
760
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
761
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
762
+ { address: options.accounts.vkRegistry, role: AccountRole.READONLY },
763
+ { address: options.accounts.user, role: AccountRole.WRITABLE_SIGNER },
764
+ { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
765
+ ];
766
+
767
+ // Nullifier records (writable PDAs)
768
+ for (const nr of options.accounts.nullifierRecords) {
769
+ accounts.push({ address: nr, role: AccountRole.WRITABLE });
770
+ }
771
+ if (options.accounts.policyApproval) {
772
+ accounts.push({ address: options.accounts.policyApproval, role: AccountRole.WRITABLE });
773
+ accounts.push({
774
+ address: config.policyProgramId ?? config.utxopiaProgramId,
775
+ role: AccountRole.READONLY,
776
+ });
777
+ }
778
+
779
+ return {
780
+ programAddress: config.utxopiaProgramId,
781
+ accounts,
782
+ data,
783
+ };
784
+ }
785
+
786
+ // =============================================================================
787
+ // JoinSplit + BTC Redeem Instruction Builder (disc=16)
788
+ // =============================================================================
789
+
790
+ /**
791
+ * Build instruction data for REDEEM (disc=15) — atomic JoinSplit + BTC withdrawal (multi-output)
792
+ *
793
+ * Combines Groth16 proof verification with RedemptionRequest PDA creation.
794
+ * Supports 1..3 public outputs, each creating a separate RedemptionRequest.
795
+ *
796
+ * Layout (4-byte common header):
797
+ * - n_inputs: u8
798
+ * - n_outputs: u8
799
+ * - n_public_outputs: u8 (1..3)
800
+ * - proof_source: u8 (0=inline, 1=buffer account)
801
+ * - proof: [u8; 256] (only if proof_source=0)
802
+ * - merkle_root: [u8; 32]
803
+ * - bound_params_hash: [u8; 32]
804
+ * - nullifiers: [[u8; 32]; n_inputs]
805
+ * - commitments_out: [[u8; 32]; n_outputs]
806
+ * - stealth_data: [ephemeral_pub(32) + encrypted_amount(8) + encrypted_token_id(32)] x n_tree_outputs
807
+ * - For each public output: amount(8) + script_len(1) + script(var) + nonce(8)
808
+ */
809
+ export function buildRedeemInstructionData(options: {
810
+ nInputs: number;
811
+ nOutputs: number;
812
+ /** Number of public (redeem) outputs. Defaults to redeemAmounts.length. */
813
+ nPublicOutputs?: number;
814
+ /** Groth16 proof (256 bytes). Omit when using buffer mode. */
815
+ proofBytes?: Uint8Array;
816
+ merkleRoot: Uint8Array;
817
+ boundParamsHash: Uint8Array;
818
+ nullifiers: Uint8Array[];
819
+ commitmentsOut: Uint8Array[];
820
+ /** Stealth data for tree outputs only (n_tree_outputs entries, 72 bytes each) */
821
+ stealthData: Uint8Array[];
822
+ /** Amount(s) to redeem in satoshis — single or array */
823
+ redeemAmounts: bigint[];
824
+ /** Bitcoin scriptPubKey(s) (raw bytes, max 62 each) — single or array */
825
+ btcScripts: Uint8Array[];
826
+ /** Unique request nonce(s) — single or array */
827
+ requestNonces: bigint[];
828
+ /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
829
+ proofSource?: 0 | 1;
830
+ }): Uint8Array {
831
+ const {
832
+ nInputs, nOutputs, proofBytes, merkleRoot, boundParamsHash,
833
+ nullifiers, commitmentsOut, stealthData, redeemAmounts, btcScripts, requestNonces,
834
+ } = options;
835
+ const nPublicOutputs = options.nPublicOutputs ?? redeemAmounts.length;
836
+ const proofSource = options.proofSource ?? 0;
837
+
838
+ if (proofSource === 0 && (!proofBytes || proofBytes.length !== 256)) {
839
+ throw new Error(`Inline mode requires 256-byte proof, got ${proofBytes?.length ?? 0}`);
840
+ }
841
+ if (nPublicOutputs < 1 || nPublicOutputs > 3) {
842
+ throw new Error(`nPublicOutputs must be 1-3, got ${nPublicOutputs}`);
843
+ }
844
+ const nTreeOutputs = nOutputs - nPublicOutputs;
845
+ if (nTreeOutputs < 0) {
846
+ throw new Error(`nOutputs (${nOutputs}) must be >= nPublicOutputs (${nPublicOutputs})`);
847
+ }
848
+ if (nullifiers.length !== nInputs) {
849
+ throw new Error(`Expected ${nInputs} nullifiers, got ${nullifiers.length}`);
850
+ }
851
+ if (commitmentsOut.length !== nOutputs) {
852
+ throw new Error(`Expected ${nOutputs} commitments, got ${commitmentsOut.length}`);
853
+ }
854
+ if (stealthData.length !== nTreeOutputs) {
855
+ throw new Error(`Expected ${nTreeOutputs} stealth data entries, got ${stealthData.length}`);
856
+ }
857
+ assertStealthDataRecordLengths(stealthData);
858
+ if (redeemAmounts.length !== nPublicOutputs) {
859
+ throw new Error(`Expected ${nPublicOutputs} redeem amounts, got ${redeemAmounts.length}`);
860
+ }
861
+ if (btcScripts.length !== nPublicOutputs) {
862
+ throw new Error(`Expected ${nPublicOutputs} BTC scripts, got ${btcScripts.length}`);
863
+ }
864
+ if (requestNonces.length !== nPublicOutputs) {
865
+ throw new Error(`Expected ${nPublicOutputs} request nonces, got ${requestNonces.length}`);
866
+ }
867
+ for (let k = 0; k < nPublicOutputs; k++) {
868
+ if (btcScripts[k].length === 0 || btcScripts[k].length > 62) {
869
+ throw new Error(`BTC script[${k}] must be 1-62 bytes, got ${btcScripts[k].length}`);
870
+ }
871
+ }
872
+
873
+ const proofSize = proofSource === 0 ? 256 : 0;
874
+ let totalScriptLen = 0;
875
+ for (const s of btcScripts) totalScriptLen += s.length;
876
+ const totalSize = 1 + 4 + proofSize + 32 + 32
877
+ + (nInputs * 32) + (nOutputs * 32) + (nTreeOutputs * STEALTH_DATA_PER_OUTPUT)
878
+ + nPublicOutputs * (8 + 1 + 8) + totalScriptLen;
879
+
880
+ const data = new Uint8Array(totalSize);
881
+ const view = new DataView(data.buffer);
882
+ let offset = 0;
883
+
884
+ // Discriminator
885
+ data[offset++] = INSTRUCTION.REDEEM;
886
+
887
+ // Header (4 bytes)
888
+ data[offset++] = nInputs;
889
+ data[offset++] = nOutputs;
890
+ data[offset++] = nPublicOutputs;
891
+ data[offset++] = proofSource;
892
+
893
+ // Proof (256 bytes, only in inline mode)
894
+ if (proofSource === 0 && proofBytes) {
895
+ data.set(proofBytes, offset);
896
+ offset += 256;
897
+ }
898
+
899
+ // Merkle root (32 bytes)
900
+ data.set(merkleRoot, offset);
901
+ offset += 32;
902
+
903
+ // Bound params hash (32 bytes)
904
+ data.set(boundParamsHash, offset);
905
+ offset += 32;
906
+
907
+ // Nullifiers
908
+ for (const nullifier of nullifiers) {
909
+ data.set(nullifier, offset);
910
+ offset += 32;
911
+ }
912
+
913
+ // Output commitments (all n_outputs, last n_public_outputs = redeem)
914
+ for (const commitment of commitmentsOut) {
915
+ data.set(commitment, offset);
916
+ offset += 32;
917
+ }
918
+
919
+ // Stealth data for tree outputs only (72 bytes each)
920
+ for (const sd of stealthData) {
921
+ data.set(sd, offset);
922
+ offset += STEALTH_DATA_PER_OUTPUT;
923
+ }
924
+
925
+ // Per-output redeem data: amount(8) + script_len(1) + script(var) + nonce(8)
926
+ for (let k = 0; k < nPublicOutputs; k++) {
927
+ view.setBigUint64(offset, redeemAmounts[k], true);
928
+ offset += 8;
929
+ data[offset++] = btcScripts[k].length;
930
+ data.set(btcScripts[k], offset);
931
+ offset += btcScripts[k].length;
932
+ view.setBigUint64(offset, requestNonces[k], true);
933
+ offset += 8;
934
+ }
935
+
936
+ return data;
937
+ }
938
+
939
+ // =============================================================================
940
+ // Public Unshield Instruction Builder
941
+ // =============================================================================
942
+
943
+ /** Unshield instruction options (multi-output) */
944
+ export interface UnshieldInstructionOptions {
945
+ /** Number of input notes being spent */
946
+ nInputs: number;
947
+ /** Number of output notes (includes burn outputs at end) */
948
+ nOutputs: number;
949
+ /** Number of public (unshield) outputs. Defaults to 1. */
950
+ nPublicOutputs?: number;
951
+ /** Groth16 proof bytes (256 bytes) */
952
+ proofBytes: Uint8Array;
953
+ /** Merkle root */
954
+ merkleRoot: Uint8Array;
955
+ /** Bound parameters hash */
956
+ boundParamsHash: Uint8Array;
957
+ /** Nullifiers (32 bytes each) */
958
+ nullifiers: Uint8Array[];
959
+ /** Output commitments (32 bytes each, last nPublicOutputs = burn commitments) */
960
+ commitmentsOut: Uint8Array[];
961
+ /** Per-output stealth data for tree outputs only */
962
+ stealthData: Uint8Array[];
963
+ /** Amount(s) being unshielded */
964
+ unshieldAmounts: bigint[];
965
+ /** Account addresses */
966
+ accounts: {
967
+ poolState: Address;
968
+ commitmentTree: Address;
969
+ vkRegistry: Address;
970
+ user: Address;
971
+ tokenConfig: Address;
972
+ vault: Address;
973
+ /** Token program for the mint (TOKEN_2022_PROGRAM_ID or TOKEN_PROGRAM_ID). Defaults to Token-2022. */
974
+ tokenProgram?: Address;
975
+ /** Public destination accounts (SPL token accounts, or direct recipients for native SOL) */
976
+ recipientTokenAccounts: Address[];
977
+ /** Nullifier record PDAs (one per input) */
978
+ nullifierRecords: Address[];
979
+ /** Required when poolState is permissioned. Appended before any proof buffer. */
980
+ policyApproval?: Address;
981
+ };
982
+ }
983
+
984
+ /**
985
+ * Build unshield instruction data (multi-output, disc=14).
986
+ *
987
+ * Layout (4-byte common header):
988
+ * - disc(1) + n_inputs(1) + n_outputs(1) + n_public_outputs(1) + proof_source(1)
989
+ * - proof(256) if inline
990
+ * - merkle_root(32) + bound_params_hash(32)
991
+ * - nullifiers(N*32) + commitments_out(M*32)
992
+ * - stealth_data(n_tree_outputs * 72)
993
+ * - amounts[P] (each u64 LE)
994
+ *
995
+ * Recipients come from the accounts array. Ordinary SPL outputs use token
996
+ * accounts; native-SOL outputs use the recipient accounts directly.
997
+ */
998
+ export function buildUnshieldInstructionData(options: {
999
+ nInputs: number;
1000
+ nOutputs: number;
1001
+ /** Number of public (unshield) outputs. Defaults to 1. */
1002
+ nPublicOutputs?: number;
1003
+ /** Groth16 proof (256 bytes). Omit when using buffer mode. */
1004
+ proofBytes?: Uint8Array;
1005
+ merkleRoot: Uint8Array;
1006
+ boundParamsHash: Uint8Array;
1007
+ nullifiers: Uint8Array[];
1008
+ commitmentsOut: Uint8Array[];
1009
+ stealthData: Uint8Array[];
1010
+ /** Amount(s) being unshielded — single or array */
1011
+ unshieldAmounts: bigint[];
1012
+ /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
1013
+ proofSource?: 0 | 1;
1014
+ }): Uint8Array {
1015
+ const { nInputs, nOutputs, proofBytes, merkleRoot, boundParamsHash, nullifiers, commitmentsOut, stealthData, unshieldAmounts } = options;
1016
+ const nPublicOutputs = options.nPublicOutputs ?? unshieldAmounts.length;
1017
+ const proofSource = options.proofSource ?? 0;
1018
+
1019
+ if (proofSource === 0 && (!proofBytes || proofBytes.length !== 256)) {
1020
+ throw new Error(`Inline mode requires 256-byte proof, got ${proofBytes?.length ?? 0}`);
1021
+ }
1022
+ if (nPublicOutputs < 1 || nPublicOutputs > 3) {
1023
+ throw new Error(`nPublicOutputs must be 1-3, got ${nPublicOutputs}`);
1024
+ }
1025
+ if (nullifiers.length !== nInputs) {
1026
+ throw new Error(`Expected ${nInputs} nullifiers, got ${nullifiers.length}`);
1027
+ }
1028
+ if (commitmentsOut.length !== nOutputs) {
1029
+ throw new Error(`Expected ${nOutputs} commitments, got ${commitmentsOut.length}`);
1030
+ }
1031
+ const nTreeOutputs = nOutputs - nPublicOutputs;
1032
+ if (nTreeOutputs < 0) {
1033
+ throw new Error(`nOutputs (${nOutputs}) must be >= nPublicOutputs (${nPublicOutputs})`);
1034
+ }
1035
+ if (stealthData.length !== nTreeOutputs) {
1036
+ throw new Error(`Expected ${nTreeOutputs} stealth data entries (tree outputs), got ${stealthData.length}`);
1037
+ }
1038
+ assertStealthDataRecordLengths(stealthData);
1039
+ if (unshieldAmounts.length !== nPublicOutputs) {
1040
+ throw new Error(`Expected ${nPublicOutputs} unshield amounts, got ${unshieldAmounts.length}`);
1041
+ }
1042
+
1043
+ const proofSize = proofSource === 0 ? 256 : 0;
1044
+ const totalSize = 1 + 4 + proofSize + 32 + 32 + (nInputs * 32) + (nOutputs * 32) + (nTreeOutputs * STEALTH_DATA_PER_OUTPUT) + (nPublicOutputs * 8);
1045
+ const data = new Uint8Array(totalSize);
1046
+ const view = new DataView(data.buffer);
1047
+
1048
+ let offset = 0;
1049
+
1050
+ // Discriminator
1051
+ data[offset++] = INSTRUCTION.UNSHIELD;
1052
+
1053
+ // Header (4 bytes)
1054
+ data[offset++] = nInputs;
1055
+ data[offset++] = nOutputs;
1056
+ data[offset++] = nPublicOutputs;
1057
+ data[offset++] = proofSource;
1058
+
1059
+ // Proof (256 bytes, only in inline mode)
1060
+ if (proofSource === 0 && proofBytes) {
1061
+ data.set(proofBytes, offset);
1062
+ offset += 256;
1063
+ }
1064
+
1065
+ // Merkle root (32 bytes)
1066
+ data.set(merkleRoot, offset);
1067
+ offset += 32;
1068
+
1069
+ // Bound params hash (32 bytes)
1070
+ data.set(boundParamsHash, offset);
1071
+ offset += 32;
1072
+
1073
+ // Nullifiers
1074
+ for (const nullifier of nullifiers) {
1075
+ data.set(nullifier, offset);
1076
+ offset += 32;
1077
+ }
1078
+
1079
+ // Output commitments (all n_outputs, last nPublicOutputs = burn)
1080
+ for (const commitment of commitmentsOut) {
1081
+ data.set(commitment, offset);
1082
+ offset += 32;
1083
+ }
1084
+
1085
+ // Stealth data for tree outputs only
1086
+ for (const sd of stealthData) {
1087
+ data.set(sd, offset);
1088
+ offset += STEALTH_DATA_PER_OUTPUT;
1089
+ }
1090
+
1091
+ // Per-output unshield amounts (u64 LE each)
1092
+ for (const amount of unshieldAmounts) {
1093
+ view.setBigUint64(offset, amount, true);
1094
+ offset += 8;
1095
+ }
1096
+
1097
+ return data;
1098
+ }
1099
+
1100
+ /**
1101
+ * Build a complete unshield instruction (multi-output, disc=14)
1102
+ *
1103
+ * Accounts:
1104
+ * 0. pool_state (read)
1105
+ * 1. commitment_tree (writable)
1106
+ * 2. vk_registry (read)
1107
+ * 3. user (signer)
1108
+ * 4. system_program (read)
1109
+ * 5. token_config (writable)
1110
+ * 6. vault (writable)
1111
+ * 7. token_program (read)
1112
+ * 8..8+P public destinations (SPL token accounts or native-SOL recipients)
1113
+ * 8+P..8+P+N nullifier_records (writable)
1114
+ */
1115
+ export function buildUnshieldInstruction(options: UnshieldInstructionOptions): Instruction {
1116
+ const config = getConfig();
1117
+ const nPublicOutputs = options.nPublicOutputs ?? options.unshieldAmounts.length;
1118
+
1119
+ const data = buildUnshieldInstructionData({
1120
+ nInputs: options.nInputs,
1121
+ nOutputs: options.nOutputs,
1122
+ nPublicOutputs,
1123
+ proofBytes: options.proofBytes,
1124
+ merkleRoot: options.merkleRoot,
1125
+ boundParamsHash: options.boundParamsHash,
1126
+ nullifiers: options.nullifiers,
1127
+ commitmentsOut: options.commitmentsOut,
1128
+ stealthData: options.stealthData,
1129
+ unshieldAmounts: options.unshieldAmounts,
1130
+ });
1131
+
1132
+ const accounts: Instruction["accounts"] = [
1133
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
1134
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
1135
+ { address: options.accounts.vkRegistry, role: AccountRole.READONLY },
1136
+ { address: options.accounts.user, role: AccountRole.WRITABLE_SIGNER },
1137
+ { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
1138
+ { address: options.accounts.tokenConfig, role: AccountRole.WRITABLE },
1139
+ { address: options.accounts.vault, role: AccountRole.WRITABLE },
1140
+ { address: options.accounts.tokenProgram ?? TOKEN_2022_PROGRAM_ID, role: AccountRole.READONLY },
1141
+ ];
1142
+
1143
+ // Public destinations (one per public output)
1144
+ for (const rta of options.accounts.recipientTokenAccounts) {
1145
+ accounts.push({ address: rta, role: AccountRole.WRITABLE });
1146
+ }
1147
+
1148
+ // Nullifier records (writable PDAs)
1149
+ for (const nr of options.accounts.nullifierRecords) {
1150
+ accounts.push({ address: nr, role: AccountRole.WRITABLE });
1151
+ }
1152
+ if (options.accounts.policyApproval) {
1153
+ accounts.push({ address: options.accounts.policyApproval, role: AccountRole.WRITABLE });
1154
+ accounts.push({
1155
+ address: config.policyProgramId ?? config.utxopiaProgramId,
1156
+ role: AccountRole.READONLY,
1157
+ });
1158
+ }
1159
+
1160
+ return {
1161
+ programAddress: config.utxopiaProgramId,
1162
+ accounts,
1163
+ data,
1164
+ };
1165
+ }
1166
+
1167
+ // Removed request_redemption/public_redeem instructions are reserved; use REDEEM for proof-checked BTC withdrawals.
1168
+ // =============================================================================
1169
+ // Timelocked Pool Update Instruction Builders
1170
+ // =============================================================================
1171
+
1172
+ /** Propose pool update instruction options */
1173
+ export interface ProposePoolUpdateOptions {
1174
+ /** New minimum deposit in satoshis */
1175
+ minDeposit: bigint;
1176
+ /** New maximum deposit in satoshis */
1177
+ maxDeposit: bigint;
1178
+ /** New service fee base in satoshis */
1179
+ serviceFee: bigint;
1180
+ /** Service fee in basis points (e.g. 30 = 0.3%). Applied immediately, no timelock. */
1181
+ serviceFeeBps?: number;
1182
+ /** Account addresses */
1183
+ accounts: {
1184
+ poolState: Address;
1185
+ authority: Address;
1186
+ };
1187
+ }
1188
+
1189
+ /**
1190
+ * Build propose_pool_update instruction data
1191
+ *
1192
+ * Layout: discriminator(1) + min_deposit(8) + max_deposit(8) + service_fee(8) + [service_fee_bps(2)] = 25 or 27 bytes
1193
+ */
1194
+ export function buildProposePoolUpdateInstructionData(
1195
+ minDeposit: bigint,
1196
+ maxDeposit: bigint,
1197
+ serviceFee: bigint,
1198
+ serviceFeeBps?: number,
1199
+ ): Uint8Array {
1200
+ const hasBps = serviceFeeBps !== undefined;
1201
+ const data = new Uint8Array(hasBps ? 27 : 25);
1202
+ const view = new DataView(data.buffer);
1203
+
1204
+ data[0] = INSTRUCTION.PROPOSE_POOL_UPDATE;
1205
+ view.setBigUint64(1, minDeposit, true);
1206
+ view.setBigUint64(9, maxDeposit, true);
1207
+ view.setBigUint64(17, serviceFee, true);
1208
+
1209
+ if (hasBps) {
1210
+ view.setUint16(25, serviceFeeBps, true);
1211
+ }
1212
+
1213
+ return data;
1214
+ }
1215
+
1216
+ /**
1217
+ * Build a complete propose_pool_update instruction
1218
+ *
1219
+ * Accounts:
1220
+ * 0. pool_state (writable)
1221
+ * 1. authority (signer)
1222
+ */
1223
+ export function buildProposePoolUpdateInstruction(options: ProposePoolUpdateOptions): Instruction {
1224
+ const config = getConfig();
1225
+
1226
+ const data = buildProposePoolUpdateInstructionData(
1227
+ options.minDeposit,
1228
+ options.maxDeposit,
1229
+ options.serviceFee,
1230
+ options.serviceFeeBps,
1231
+ );
1232
+
1233
+ return {
1234
+ programAddress: config.utxopiaProgramId,
1235
+ accounts: [
1236
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
1237
+ { address: options.accounts.authority, role: AccountRole.WRITABLE_SIGNER },
1238
+ ],
1239
+ data,
1240
+ };
1241
+ }
1242
+
1243
+ /** Execute pool update instruction options */
1244
+ export interface ExecutePoolUpdateOptions {
1245
+ accounts: {
1246
+ poolState: Address;
1247
+ /** Pool authority — must sign (execute is authority-only; audit f11). */
1248
+ authority: Address;
1249
+ };
1250
+ }
1251
+
1252
+ /**
1253
+ * Build execute_pool_update instruction data
1254
+ *
1255
+ * Layout: discriminator(1) = 1 byte
1256
+ */
1257
+ export function buildExecutePoolUpdateInstructionData(): Uint8Array {
1258
+ return new Uint8Array([INSTRUCTION.EXECUTE_POOL_UPDATE]);
1259
+ }
1260
+
1261
+ /**
1262
+ * Build a complete execute_pool_update instruction (authority-only)
1263
+ *
1264
+ * Accounts:
1265
+ * 0. pool_state (writable)
1266
+ * 1. authority (signer)
1267
+ */
1268
+ export function buildExecutePoolUpdateInstruction(options: ExecutePoolUpdateOptions): Instruction {
1269
+ const config = getConfig();
1270
+
1271
+ return {
1272
+ programAddress: config.utxopiaProgramId,
1273
+ accounts: [
1274
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
1275
+ { address: options.accounts.authority, role: AccountRole.READONLY_SIGNER },
1276
+ ],
1277
+ data: buildExecutePoolUpdateInstructionData(),
1278
+ };
1279
+ }
1280
+
1281
+ /** Cancel pool update instruction options */
1282
+ export interface CancelPoolUpdateOptions {
1283
+ accounts: {
1284
+ poolState: Address;
1285
+ authority: Address;
1286
+ };
1287
+ }
1288
+
1289
+ /**
1290
+ * Build cancel_pool_update instruction data
1291
+ *
1292
+ * Layout: discriminator(1) = 1 byte
1293
+ */
1294
+ export function buildCancelPoolUpdateInstructionData(): Uint8Array {
1295
+ return new Uint8Array([INSTRUCTION.CANCEL_POOL_UPDATE]);
1296
+ }
1297
+
1298
+ /**
1299
+ * Build a complete cancel_pool_update instruction
1300
+ *
1301
+ * Accounts:
1302
+ * 0. pool_state (writable)
1303
+ * 1. authority (signer)
1304
+ */
1305
+ export function buildCancelPoolUpdateInstruction(options: CancelPoolUpdateOptions): Instruction {
1306
+ const config = getConfig();
1307
+
1308
+ return {
1309
+ programAddress: config.utxopiaProgramId,
1310
+ accounts: [
1311
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
1312
+ { address: options.accounts.authority, role: AccountRole.WRITABLE_SIGNER },
1313
+ ],
1314
+ data: buildCancelPoolUpdateInstructionData(),
1315
+ };
1316
+ }
1317
+
1318
+ // =============================================================================
1319
+ // Rotate Tree Instruction Builder (disc=20)
1320
+ // =============================================================================
1321
+
1322
+ /** Rotate tree instruction options */
1323
+ export interface RotateTreeOptions {
1324
+ accounts: {
1325
+ poolState: Address;
1326
+ currentTree: Address;
1327
+ newTree: Address;
1328
+ authority: Address;
1329
+ systemProgram: Address;
1330
+ };
1331
+ }
1332
+
1333
+ /**
1334
+ * Build rotate_tree instruction data (disc=20, no payload)
1335
+ */
1336
+ export function buildRotateTreeInstructionData(): Uint8Array {
1337
+ return new Uint8Array([INSTRUCTION.ROTATE_TREE]);
1338
+ }
1339
+
1340
+ /**
1341
+ * Build a complete rotate_tree instruction
1342
+ *
1343
+ * Accounts:
1344
+ * 0. pool_state (writable)
1345
+ * 1. current_tree (writable) — must be full
1346
+ * 2. new_tree (writable) — to be created
1347
+ * 3. authority (signer)
1348
+ * 4. system_program
1349
+ */
1350
+ export function buildRotateTreeInstruction(options: RotateTreeOptions): Instruction {
1351
+ const config = getConfig();
1352
+
1353
+ return {
1354
+ programAddress: config.utxopiaProgramId,
1355
+ accounts: [
1356
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
1357
+ { address: options.accounts.currentTree, role: AccountRole.WRITABLE },
1358
+ { address: options.accounts.newTree, role: AccountRole.WRITABLE },
1359
+ { address: options.accounts.authority, role: AccountRole.WRITABLE_SIGNER },
1360
+ { address: options.accounts.systemProgram, role: AccountRole.READONLY },
1361
+ ],
1362
+ data: buildRotateTreeInstructionData(),
1363
+ };
1364
+ }
1365
+
1366
+ /** Only policy decisions are delegated; asset-bearing pool/tree state stays on Solana. */
1367
+ export type MagicBlockDelegateTarget = "policyApproval";
1368
+
1369
+ export interface MagicBlockDelegateInstructionOptions {
1370
+ accounts: {
1371
+ payer: Address;
1372
+ authority: Address;
1373
+ poolState: Address;
1374
+ delegatedAccount: Address;
1375
+ ownerProgram?: Address;
1376
+ buffer: Address;
1377
+ delegationRecord: Address;
1378
+ delegationMetadata: Address;
1379
+ systemProgram?: Address;
1380
+ };
1381
+ target: MagicBlockDelegateTarget;
1382
+ commitFrequencyMs: number;
1383
+ validator?: Address;
1384
+ }
1385
+
1386
+ export interface MagicBlockCommitInstructionOptions {
1387
+ accounts: {
1388
+ payer: Address;
1389
+ /** Required for undelegation. Commit-only callers may omit it. */
1390
+ authority?: Address;
1391
+ magicContext?: Address;
1392
+ magicProgram?: Address;
1393
+ poolState: Address;
1394
+ commitmentTree: Address;
1395
+ nullifierAccounts: Address[];
1396
+ };
1397
+ nullifierHashes: Uint8Array[];
1398
+ allowUndelegation?: boolean;
1399
+ }
1400
+
1401
+ export type MagicBlockPerPermissionOperation = "create" | "update" | "close";
1402
+
1403
+ export interface MagicBlockPerPermissionMember {
1404
+ address: Address;
1405
+ flags: number;
1406
+ }
1407
+
1408
+ export interface MagicBlockPerPermissionInstructionOptions {
1409
+ operation: MagicBlockPerPermissionOperation;
1410
+ target: MagicBlockDelegateTarget;
1411
+ members?: MagicBlockPerPermissionMember[];
1412
+ accounts: {
1413
+ authority: Address;
1414
+ poolState: Address;
1415
+ permissionedAccount: Address;
1416
+ permission: Address;
1417
+ ephemeralVault?: Address;
1418
+ magicProgram?: Address;
1419
+ permissionProgram?: Address;
1420
+ };
1421
+ }
1422
+
1423
+ function magicBlockDelegateTargetByte(target: MagicBlockDelegateTarget): number {
1424
+ if (target === "policyApproval") return 2;
1425
+ throw new Error(`Unsupported MagicBlock delegate target: ${target}`);
1426
+ }
1427
+
1428
+ /**
1429
+ * Build magicblock_delegate instruction data (disc=32).
1430
+ */
1431
+ export function buildMagicBlockDelegateInstructionData(options: {
1432
+ target: MagicBlockDelegateTarget;
1433
+ commitFrequencyMs: number;
1434
+ validator?: Address;
1435
+ }): Uint8Array {
1436
+ if (!Number.isInteger(options.commitFrequencyMs) || options.commitFrequencyMs < 0) {
1437
+ throw new Error("commitFrequencyMs must be a non-negative u32");
1438
+ }
1439
+ if (options.commitFrequencyMs > 0xffffffff) {
1440
+ throw new Error("commitFrequencyMs must fit in u32");
1441
+ }
1442
+
1443
+ const data = new Uint8Array(options.validator ? 38 : 6);
1444
+ const view = new DataView(data.buffer);
1445
+ data[0] = INSTRUCTION.MAGICBLOCK_DELEGATE;
1446
+ data[1] = magicBlockDelegateTargetByte(options.target);
1447
+ view.setUint32(2, options.commitFrequencyMs, true);
1448
+ if (options.validator) {
1449
+ data.set(addressToBytes(options.validator), 6);
1450
+ }
1451
+ return data;
1452
+ }
1453
+
1454
+ /**
1455
+ * Build a complete magicblock_delegate instruction.
1456
+ */
1457
+ export function buildMagicBlockDelegateInstruction(
1458
+ options: MagicBlockDelegateInstructionOptions
1459
+ ): Instruction {
1460
+ const config = getConfig();
1461
+
1462
+ return {
1463
+ programAddress: config.policyProgramId ?? config.utxopiaProgramId,
1464
+ accounts: [
1465
+ { address: options.accounts.payer, role: AccountRole.WRITABLE_SIGNER },
1466
+ { address: options.accounts.authority, role: AccountRole.READONLY_SIGNER },
1467
+ { address: options.accounts.poolState, role: AccountRole.READONLY },
1468
+ { address: options.accounts.delegatedAccount, role: AccountRole.WRITABLE },
1469
+ {
1470
+ address: options.accounts.ownerProgram ?? config.policyProgramId ?? config.utxopiaProgramId,
1471
+ role: AccountRole.READONLY,
1472
+ },
1473
+ { address: options.accounts.buffer, role: AccountRole.WRITABLE },
1474
+ { address: options.accounts.delegationRecord, role: AccountRole.WRITABLE },
1475
+ { address: options.accounts.delegationMetadata, role: AccountRole.WRITABLE },
1476
+ { address: options.accounts.systemProgram ?? SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
1477
+ { address: MAGICBLOCK_DELEGATION_PROGRAM_ID, role: AccountRole.READONLY },
1478
+ ],
1479
+ data: buildMagicBlockDelegateInstructionData({
1480
+ target: options.target,
1481
+ commitFrequencyMs: options.commitFrequencyMs,
1482
+ validator: options.validator,
1483
+ }),
1484
+ };
1485
+ }
1486
+
1487
+ /**
1488
+ * Build magicblock_commit instruction data (disc=33).
1489
+ */
1490
+ export function buildMagicBlockCommitInstructionData(options: {
1491
+ nullifierHashes: Uint8Array[];
1492
+ allowUndelegation?: boolean;
1493
+ }): Uint8Array {
1494
+ if (
1495
+ options.nullifierHashes.length === 0 ||
1496
+ options.nullifierHashes.length > 10
1497
+ ) {
1498
+ throw new Error("MagicBlock commits require 1-10 nullifier hashes");
1499
+ }
1500
+ for (const hash of options.nullifierHashes) {
1501
+ if (hash.length !== 32) {
1502
+ throw new Error("Each MagicBlock commit nullifier hash must be 32 bytes");
1503
+ }
1504
+ }
1505
+ const data = new Uint8Array(4 + options.nullifierHashes.length * 32);
1506
+ data[0] = INSTRUCTION.MAGICBLOCK_COMMIT;
1507
+ data[1] = 1;
1508
+ data[2] = options.allowUndelegation ? 1 : 0;
1509
+ data[3] = options.nullifierHashes.length;
1510
+ options.nullifierHashes.forEach((hash, index) => data.set(hash, 4 + index * 32));
1511
+ return data;
1512
+ }
1513
+
1514
+ /**
1515
+ * Build a complete magicblock_commit instruction.
1516
+ */
1517
+ export function buildMagicBlockCommitInstruction(
1518
+ options: MagicBlockCommitInstructionOptions
1519
+ ): Instruction {
1520
+ const config = getConfig();
1521
+ if (
1522
+ options.accounts.nullifierAccounts.length !== options.nullifierHashes.length
1523
+ ) {
1524
+ throw new Error("Nullifier account and hash counts must match");
1525
+ }
1526
+
1527
+ const accounts = [
1528
+ { address: options.accounts.payer, role: AccountRole.READONLY_SIGNER },
1529
+ {
1530
+ address: options.accounts.authority ?? options.accounts.payer,
1531
+ role: AccountRole.READONLY_SIGNER,
1532
+ },
1533
+ {
1534
+ address: options.accounts.magicContext ?? MAGICBLOCK_MAGIC_CONTEXT_ID,
1535
+ role: AccountRole.WRITABLE,
1536
+ },
1537
+ { address: options.accounts.magicProgram ?? MAGICBLOCK_MAGIC_PROGRAM_ID, role: AccountRole.READONLY },
1538
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
1539
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
1540
+ ];
1541
+
1542
+ for (const nullifier of options.accounts.nullifierAccounts) {
1543
+ accounts.push({ address: nullifier, role: AccountRole.WRITABLE });
1544
+ }
1545
+
1546
+ return {
1547
+ programAddress: config.utxopiaProgramId,
1548
+ accounts,
1549
+ data: buildMagicBlockCommitInstructionData({
1550
+ nullifierHashes: options.nullifierHashes,
1551
+ allowUndelegation: options.allowUndelegation,
1552
+ }),
1553
+ };
1554
+ }
1555
+
1556
+ function magicBlockPerOperationByte(operation: MagicBlockPerPermissionOperation): number {
1557
+ if (operation === "create") return 0;
1558
+ if (operation === "update") return 1;
1559
+ if (operation === "close") return 2;
1560
+ throw new Error(`Unsupported MagicBlock PER operation: ${operation}`);
1561
+ }
1562
+
1563
+ export function buildMagicBlockPerPermissionInstructionData(options: {
1564
+ operation: MagicBlockPerPermissionOperation;
1565
+ target: MagicBlockDelegateTarget;
1566
+ members?: MagicBlockPerPermissionMember[];
1567
+ }): Uint8Array {
1568
+ const members = options.members ?? [];
1569
+ if (options.operation === "close") {
1570
+ if (members.length !== 0) {
1571
+ throw new Error("Closing a MagicBlock PER permission does not accept members");
1572
+ }
1573
+ } else {
1574
+ if (members.length === 0 || members.length > MAGICBLOCK_MAX_PER_MEMBERS) {
1575
+ throw new Error(
1576
+ `MagicBlock PER permissions require 1-${MAGICBLOCK_MAX_PER_MEMBERS} members`
1577
+ );
1578
+ }
1579
+ if (!members.some((member) => (member.flags & MAGICBLOCK_PER_MEMBER_FLAGS.authority) !== 0)) {
1580
+ throw new Error("MagicBlock PER permissions must retain an authority member");
1581
+ }
1582
+ }
1583
+
1584
+ const allowedFlags = Object.values(MAGICBLOCK_PER_MEMBER_FLAGS).reduce(
1585
+ (combined, flag) => combined | flag,
1586
+ 0
1587
+ );
1588
+ const data = new Uint8Array(4 + members.length * 33);
1589
+ data[0] = INSTRUCTION.MAGICBLOCK_PER_PERMISSION;
1590
+ data[1] = magicBlockPerOperationByte(options.operation);
1591
+ data[2] = magicBlockDelegateTargetByte(options.target);
1592
+ data[3] = members.length;
1593
+ members.forEach((member, index) => {
1594
+ if (!Number.isInteger(member.flags) || member.flags < 0 || member.flags > 0xff) {
1595
+ throw new Error("MagicBlock PER member flags must fit in u8");
1596
+ }
1597
+ if ((member.flags & ~allowedFlags) !== 0) {
1598
+ throw new Error("MagicBlock PER member flags contain unsupported bits");
1599
+ }
1600
+ const offset = 4 + index * 33;
1601
+ data[offset] = member.flags;
1602
+ data.set(addressToBytes(member.address), offset + 1);
1603
+ });
1604
+ return data;
1605
+ }
1606
+
1607
+ export function buildMagicBlockPerPermissionInstruction(
1608
+ options: MagicBlockPerPermissionInstructionOptions
1609
+ ): Instruction {
1610
+ const config = getConfig();
1611
+ return {
1612
+ programAddress: config.policyProgramId ?? config.utxopiaProgramId,
1613
+ accounts: [
1614
+ { address: options.accounts.authority, role: AccountRole.READONLY_SIGNER },
1615
+ { address: options.accounts.poolState, role: AccountRole.READONLY },
1616
+ { address: options.accounts.permissionedAccount, role: AccountRole.WRITABLE },
1617
+ { address: options.accounts.permission, role: AccountRole.WRITABLE },
1618
+ {
1619
+ address: options.accounts.ephemeralVault ?? MAGICBLOCK_EPHEMERAL_VAULT_ID,
1620
+ role: AccountRole.WRITABLE,
1621
+ },
1622
+ {
1623
+ address: options.accounts.magicProgram ?? MAGICBLOCK_MAGIC_PROGRAM_ID,
1624
+ role: AccountRole.READONLY,
1625
+ },
1626
+ {
1627
+ address: options.accounts.permissionProgram ?? MAGICBLOCK_PERMISSION_PROGRAM_ID,
1628
+ role: AccountRole.READONLY,
1629
+ },
1630
+ ],
1631
+ data: buildMagicBlockPerPermissionInstructionData(options),
1632
+ };
1633
+ }
1634
+
1635
+ export type PolicyApprovalDecision = "approve" | "reject";
1636
+
1637
+ /** Must match `MAX_INTENT_PARTS` in the asset program. */
1638
+ export const MAX_POLICY_INTENT_PARTS = 3;
1639
+
1640
+ /**
1641
+ * Build the intent parts a spend's policy approval commits to.
1642
+ *
1643
+ * The layout mirrors what the asset program hashes, and deliberately carries no
1644
+ * proof machinery: the merkle root, output commitments and stealth data all move
1645
+ * when a spend is re-proved, and a spend has to be re-proved whenever the root
1646
+ * advances — which is exactly while the authority is deciding. Binding them
1647
+ * would make every approval expire the moment someone else deposits.
1648
+ *
1649
+ * Amounts and BTC scripts are encoded exactly as the instruction encodes them,
1650
+ * because the program hashes the same trailing bytes it parses.
1651
+ */
1652
+ export function buildPolicyIntentParts(
1653
+ options:
1654
+ | { action: 13; nullifiers: Uint8Array[] }
1655
+ | {
1656
+ action: 14;
1657
+ nullifiers: Uint8Array[];
1658
+ unshieldAmounts: bigint[];
1659
+ /** OWNER of each recipient token account — what the payout credits. */
1660
+ recipientOwners: Uint8Array[];
1661
+ }
1662
+ | {
1663
+ action: 15;
1664
+ nullifiers: Uint8Array[];
1665
+ redeemAmounts: bigint[];
1666
+ btcScripts: Uint8Array[];
1667
+ requestNonces: bigint[];
1668
+ },
1669
+ ): Uint8Array[] {
1670
+ const nullifiers = concatBytes(options.nullifiers);
1671
+
1672
+ if (options.action === INSTRUCTION.TRANSACT) {
1673
+ // An internal transfer reveals no amount and no external destination, so the
1674
+ // only thing being decided is whether these notes may be spent at all.
1675
+ return [nullifiers];
1676
+ }
1677
+
1678
+ if (options.action === INSTRUCTION.UNSHIELD) {
1679
+ const amounts = new Uint8Array(options.unshieldAmounts.length * 8);
1680
+ const view = new DataView(amounts.buffer);
1681
+ options.unshieldAmounts.forEach((amount, i) => view.setBigUint64(i * 8, amount, true));
1682
+ for (const owner of options.recipientOwners) {
1683
+ if (owner.length !== 32) throw new Error("recipient owner must be 32 bytes");
1684
+ }
1685
+ return [nullifiers, amounts, concatBytes(options.recipientOwners)];
1686
+ }
1687
+
1688
+ // redeem: amount(8) + script_len(1) + script(var) + nonce(8), per output
1689
+ const { redeemAmounts, btcScripts, requestNonces } = options;
1690
+ if (redeemAmounts.length !== btcScripts.length || btcScripts.length !== requestNonces.length) {
1691
+ throw new Error("redeem amounts, scripts and nonces must be the same length");
1692
+ }
1693
+ const size = redeemAmounts.length * 17 + btcScripts.reduce((n, s) => n + s.length, 0);
1694
+ const outputs = new Uint8Array(size);
1695
+ const view = new DataView(outputs.buffer);
1696
+ let offset = 0;
1697
+ for (let k = 0; k < redeemAmounts.length; k++) {
1698
+ view.setBigUint64(offset, redeemAmounts[k], true);
1699
+ offset += 8;
1700
+ outputs[offset++] = btcScripts[k].length;
1701
+ outputs.set(btcScripts[k], offset);
1702
+ offset += btcScripts[k].length;
1703
+ view.setBigUint64(offset, requestNonces[k], true);
1704
+ offset += 8;
1705
+ }
1706
+ return [nullifiers, outputs];
1707
+ }
1708
+
1709
+ function concatBytes(chunks: Uint8Array[]): Uint8Array {
1710
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
1711
+ let offset = 0;
1712
+ for (const chunk of chunks) {
1713
+ out.set(chunk, offset);
1714
+ offset += chunk.length;
1715
+ }
1716
+ return out;
1717
+ }
1718
+
1719
+ /**
1720
+ * Mirror of `compute_policy_request_hash` in the asset program.
1721
+ *
1722
+ * The approval commits to what the authority decides — which notes are spent,
1723
+ * how much leaves, and where it goes — never to the whole instruction. Binding
1724
+ * the payload would tie an approval to one exact proof, and a spend has to be
1725
+ * re-proved whenever the merkle root moves on, which is precisely while the
1726
+ * authority is deciding.
1727
+ *
1728
+ * Parts, in the order the program hashes them:
1729
+ * - `transact`: [nullifiers]
1730
+ * - `unshield`: [nullifiers, amounts, recipientOwners]
1731
+ * - `redeem`: [nullifiers, amountsScriptsAndNonces]
1732
+ * - value entry (`shield` / `completeDeposit`): [instructionDataWithoutDiscriminator]
1733
+ *
1734
+ * Each part is folded to a fixed 32 bytes before the parts are joined, so their
1735
+ * boundaries cannot be slid. Any drift from the on-chain version makes every
1736
+ * Verified spend fail with PolicyApprovalMismatch.
1737
+ */
1738
+ export function buildPolicyRequestHash(options: {
1739
+ programId: Address;
1740
+ poolState: Address;
1741
+ actor: Address;
1742
+ /** Discriminator of the asset instruction this approval covers. */
1743
+ action: number;
1744
+ intentParts: Uint8Array[];
1745
+ }): Uint8Array {
1746
+ if (
1747
+ options.intentParts.length < 1 ||
1748
+ options.intentParts.length > MAX_POLICY_INTENT_PARTS
1749
+ ) {
1750
+ throw new Error(
1751
+ `Policy intent must have 1-${MAX_POLICY_INTENT_PARTS} parts`,
1752
+ );
1753
+ }
1754
+ const domain = new TextEncoder().encode("UTXOPIA_POLICY_APPROVAL_V1");
1755
+ const chunks = [
1756
+ domain,
1757
+ addressToBytes(options.programId),
1758
+ addressToBytes(options.poolState),
1759
+ addressToBytes(options.actor),
1760
+ Uint8Array.of(options.action),
1761
+ Uint8Array.of(options.intentParts.length),
1762
+ ...options.intentParts.map((part) => sha256(part)),
1763
+ ];
1764
+ const length = chunks.reduce((total, chunk) => total + chunk.length, 0);
1765
+ const preimage = new Uint8Array(length);
1766
+ let offset = 0;
1767
+ for (const chunk of chunks) {
1768
+ preimage.set(chunk, offset);
1769
+ offset += chunk.length;
1770
+ }
1771
+ return sha256(preimage);
1772
+ }
1773
+
1774
+ export interface InitializePolicyApprovalOptions {
1775
+ action: number;
1776
+ expiresAtSlot: bigint;
1777
+ actor: Address;
1778
+ requestHash: Uint8Array;
1779
+ nonce: Uint8Array;
1780
+ accounts: {
1781
+ payer: Address;
1782
+ poolState: Address;
1783
+ policyApproval: Address;
1784
+ systemProgram?: Address;
1785
+ };
1786
+ }
1787
+
1788
+ export function buildInitializePolicyApprovalInstructionData(
1789
+ options: Omit<InitializePolicyApprovalOptions, "accounts">
1790
+ ): Uint8Array {
1791
+ if (!Number.isInteger(options.action) || options.action < 0 || options.action > 0xff) {
1792
+ throw new Error("Policy approval action must fit in u8");
1793
+ }
1794
+ if (options.requestHash.length !== 32 || options.nonce.length !== 32) {
1795
+ throw new Error("Policy approval requestHash and nonce must be 32 bytes");
1796
+ }
1797
+ const data = new Uint8Array(106);
1798
+ const view = new DataView(data.buffer);
1799
+ data[0] = INSTRUCTION.INITIALIZE_POLICY_APPROVAL;
1800
+ data[1] = options.action;
1801
+ view.setBigUint64(2, options.expiresAtSlot, true);
1802
+ data.set(addressToBytes(options.actor), 10);
1803
+ data.set(options.requestHash, 42);
1804
+ data.set(options.nonce, 74);
1805
+ return data;
1806
+ }
1807
+
1808
+ export function buildInitializePolicyApprovalInstruction(
1809
+ options: InitializePolicyApprovalOptions
1810
+ ): Instruction {
1811
+ const config = getConfig();
1812
+ return {
1813
+ programAddress: config.policyProgramId ?? config.utxopiaProgramId,
1814
+ accounts: [
1815
+ { address: options.accounts.payer, role: AccountRole.WRITABLE_SIGNER },
1816
+ { address: options.accounts.poolState, role: AccountRole.READONLY },
1817
+ { address: options.accounts.policyApproval, role: AccountRole.WRITABLE },
1818
+ {
1819
+ address: options.accounts.systemProgram ?? SYSTEM_PROGRAM_ADDRESS,
1820
+ role: AccountRole.READONLY,
1821
+ },
1822
+ ],
1823
+ data: buildInitializePolicyApprovalInstructionData(options),
1824
+ };
1825
+ }
1826
+
1827
+ export function buildPolicyApprovalDecisionInstruction(options: {
1828
+ decision: PolicyApprovalDecision;
1829
+ accounts: { policyAuthority: Address; policyApproval: Address };
1830
+ }): Instruction {
1831
+ const config = getConfig();
1832
+ return {
1833
+ programAddress: config.policyProgramId ?? config.utxopiaProgramId,
1834
+ accounts: [
1835
+ { address: options.accounts.policyAuthority, role: AccountRole.READONLY_SIGNER },
1836
+ { address: options.accounts.policyApproval, role: AccountRole.WRITABLE },
1837
+ ],
1838
+ data: new Uint8Array([
1839
+ INSTRUCTION.POLICY_APPROVAL_DECISION,
1840
+ options.decision === "approve" ? 1 : 2,
1841
+ ]),
1842
+ };
1843
+ }
1844
+
1845
+ export function buildPolicyApprovalCommitInstruction(options: {
1846
+ accounts: {
1847
+ payer: Address;
1848
+ policyApproval: Address;
1849
+ magicContext?: Address;
1850
+ magicProgram?: Address;
1851
+ };
1852
+ }): Instruction {
1853
+ const config = getConfig();
1854
+ return {
1855
+ programAddress: config.policyProgramId ?? config.utxopiaProgramId,
1856
+ accounts: [
1857
+ { address: options.accounts.payer, role: AccountRole.READONLY_SIGNER },
1858
+ {
1859
+ address: options.accounts.magicContext ?? MAGICBLOCK_MAGIC_CONTEXT_ID,
1860
+ role: AccountRole.WRITABLE,
1861
+ },
1862
+ {
1863
+ address: options.accounts.magicProgram ?? MAGICBLOCK_MAGIC_PROGRAM_ID,
1864
+ role: AccountRole.READONLY,
1865
+ },
1866
+ { address: options.accounts.policyApproval, role: AccountRole.WRITABLE },
1867
+ ],
1868
+ data: new Uint8Array([INSTRUCTION.POLICY_APPROVAL_COMMIT]),
1869
+ };
1870
+ }
1871
+
1872
+ // =============================================================================
1873
+ // Redemption Request PDA Derivation
1874
+ // =============================================================================
1875
+
1876
+ // deriveRedemptionRequestPDA lived here too, over inlined copies of the same seeds pda.ts
1877
+ // already builds — and it returned `address: userBytes`, the caller's own pubkey rather than the
1878
+ // derived address, leaving a comment telling you to derive it yourself. Nothing used it. Use
1879
+ // `deriveRedemptionRequestPDA` from pda.ts, which returns the real PDA.
1880
+
1881
+
1882
+ // =============================================================================
1883
+ // BTC Light Client Verify Transaction (disc=2)
1884
+ // =============================================================================
1885
+
1886
+ /**
1887
+ * Build btc-light-client verify_transaction instruction data (disc=2)
1888
+ *
1889
+ * Layout (after disc byte):
1890
+ * txid(32) + block_hash(32) + tx_size(u32 LE) + merkle_proof(variable)
1891
+ *
1892
+ * Merkle proof sub-layout:
1893
+ * proof_txid(32) + path_bits(u32 LE) + path_len(u8) + tx_index(u32 LE) + siblings(32 * path_len)
1894
+ */
1895
+ export function buildVerifyTransactionInstructionData(params: {
1896
+ txid: Uint8Array; // 32 bytes, internal byte order
1897
+ blockHash: Uint8Array; // 32 bytes
1898
+ txSize: number; // raw tx size in ChadBuffer (after 32-byte authority)
1899
+ txIndex: number;
1900
+ merkleSiblings: Uint8Array[]; // each 32 bytes, internal byte order
1901
+ pathBits: number; // bitmask of path direction
1902
+ }): Uint8Array {
1903
+ const { txid, blockHash, txSize, txIndex, merkleSiblings, pathBits } = params;
1904
+ const pathLen = merkleSiblings.length;
1905
+
1906
+ // disc(1) + txid(32) + blockHash(32) + txSize(4) + proofTxid(32) + pathBits(4) + pathLen(1) + txIndex(4) + siblings(32*N)
1907
+ const totalSize = 1 + 32 + 32 + 4 + 32 + 4 + 1 + 4 + 32 * pathLen;
1908
+ const data = new Uint8Array(totalSize);
1909
+ const view = new DataView(data.buffer);
1910
+ let offset = 0;
1911
+
1912
+ data[offset++] = 2; // discriminator for verify_transaction
1913
+ data.set(txid, offset); offset += 32;
1914
+ data.set(blockHash, offset); offset += 32;
1915
+ view.setUint32(offset, txSize, true); offset += 4;
1916
+
1917
+ // Merkle proof sub-layout
1918
+ data.set(txid, offset); offset += 32; // proof_txid = txid
1919
+ view.setUint32(offset, pathBits, true); offset += 4;
1920
+ data[offset++] = pathLen;
1921
+ view.setUint32(offset, txIndex, true); offset += 4;
1922
+ for (const sibling of merkleSiblings) {
1923
+ data.set(sibling, offset); offset += 32;
1924
+ }
1925
+
1926
+ return data;
1927
+ }
1928
+
1929
+ // =============================================================================
1930
+ // UTXOpia Complete Deposit (disc=11)
1931
+ // =============================================================================
1932
+
1933
+ /**
1934
+ * Build utxopia complete_deposit instruction data (disc=11)
1935
+ *
1936
+ * npk + ephemeral_pub are extracted ON-CHAIN from the deposit TX OP_RETURN.
1937
+ * Amount is extracted from the SPV-verified sweep TX.
1938
+ *
1939
+ * Layout: disc(1) + sweep_txid(32) + block_height(u64 LE)
1940
+ * + sweep_tx_size(u32 LE) + deposit_tx_size(u32 LE) + deposit_txid(32) = 81 bytes
1941
+ */
1942
+ export function buildCompleteDepositInstructionData(params: {
1943
+ sweepTxid: Uint8Array; // 32 bytes, internal byte order
1944
+ blockHeight: number;
1945
+ sweepTxSize: number;
1946
+ depositTxSize: number;
1947
+ depositTxid: Uint8Array; // 32 bytes, internal byte order
1948
+ }): Uint8Array {
1949
+ const data = new Uint8Array(81);
1950
+ const view = new DataView(data.buffer);
1951
+ let offset = 0;
1952
+
1953
+ data[offset++] = INSTRUCTION.COMPLETE_DEPOSIT;
1954
+ data.set(params.sweepTxid, offset); offset += 32;
1955
+ view.setBigUint64(offset, BigInt(params.blockHeight), true); offset += 8;
1956
+ view.setUint32(offset, params.sweepTxSize, true); offset += 4;
1957
+ view.setUint32(offset, params.depositTxSize, true); offset += 4;
1958
+ data.set(params.depositTxid, offset); offset += 32;
1959
+
1960
+ return data;
1961
+ }
1962
+
1963
+ // =============================================================================
1964
+ // UTXOpia Verify Deposit (disc=25) — OP_RETURN-free
1965
+ // =============================================================================
1966
+
1967
+ /**
1968
+ * Build utxopia verify_deposit instruction data (disc=25).
1969
+ *
1970
+ * The OP_RETURN-free deposit path. `notePublicKey` + `ephemeralPubkey` travel in
1971
+ * instruction data instead of in the Bitcoin transaction, and the program proves
1972
+ * them against the deposit output's tapleaf — a different key pair derives a
1973
+ * different leaf, and so a different address, which the funding transaction did
1974
+ * not pay. Nothing marks the deposit as a UTXOpia transaction on chain, so any
1975
+ * wallet or exchange that can send to a P2TR address can fund it.
1976
+ *
1977
+ * Derive the address with `deriveDepositAddress(depositTweakCommitment(npk, eph),
1978
+ * ikaXOnlyPubkey)`. Both keys are hashed into the leaf, so a caller cannot swap
1979
+ * in an ephemeral key that leaves the note undiscoverable.
1980
+ *
1981
+ * No sweep: the deposit output's tapleaf names the pool's own dWallet key, so it
1982
+ * is already under pool custody and is recorded as a pool UTXO directly. The
1983
+ * SPV-verified transaction must therefore BE the deposit — `depositTxSize` is 0
1984
+ * and `depositTxid` defaults to `sweepTxid`. The receipt PDA is seeded
1985
+ * `["deposit_receipt", txid, vout]`, so pass `depositVout` to
1986
+ * `deriveDepositReceiptPDA` for this flow.
1987
+ *
1988
+ * Layout: disc(1) + sweep_txid(32) + block_height(u64 LE) + sweep_tx_size(u32 LE)
1989
+ * + deposit_tx_size(u32 LE) + deposit_txid(32) + ephemeral_pubkey(32)
1990
+ * + note_public_key(32) + deposit_vout(u32 LE) = 149 bytes
1991
+ */
1992
+ export function buildVerifyDepositInstructionData(params: {
1993
+ sweepTxid: Uint8Array; // 32 bytes, internal byte order — the SPV-proven tx
1994
+ blockHeight: number;
1995
+ sweepTxSize: number;
1996
+ depositTxSize?: number; // must be 0 or omitted: there is no second transaction
1997
+ depositTxid?: Uint8Array; // defaults to sweepTxid, which it must equal
1998
+ ephemeralPubkey: Uint8Array; // 32 bytes
1999
+ notePublicKey: Uint8Array; // 32 bytes
2000
+ depositVout: number;
2001
+ }): Uint8Array {
2002
+ const depositTxid = params.depositTxid ?? params.sweepTxid;
2003
+ for (const [name, value] of [
2004
+ ["sweepTxid", params.sweepTxid],
2005
+ ["depositTxid", depositTxid],
2006
+ ["ephemeralPubkey", params.ephemeralPubkey],
2007
+ ["notePublicKey", params.notePublicKey],
2008
+ ] as const) {
2009
+ if (value.length !== 32) {
2010
+ throw new Error(`${name} must be 32 bytes, got ${value.length}`);
2011
+ }
2012
+ }
2013
+ if (params.depositTxSize) {
2014
+ throw new Error("verify_deposit takes no second transaction: depositTxSize must be 0");
2015
+ }
2016
+ if (toHex(depositTxid) !== toHex(params.sweepTxid)) {
2017
+ throw new Error("verify_deposit proves the deposit itself: depositTxid must equal sweepTxid");
2018
+ }
2019
+
2020
+ const data = new Uint8Array(149);
2021
+ const view = new DataView(data.buffer);
2022
+ let offset = 0;
2023
+
2024
+ data[offset++] = INSTRUCTION.VERIFY_DEPOSIT;
2025
+ data.set(params.sweepTxid, offset); offset += 32;
2026
+ view.setBigUint64(offset, BigInt(params.blockHeight), true); offset += 8;
2027
+ view.setUint32(offset, params.sweepTxSize, true); offset += 4;
2028
+ view.setUint32(offset, 0, true); offset += 4; // no second transaction
2029
+ data.set(depositTxid, offset); offset += 32;
2030
+ data.set(params.ephemeralPubkey, offset); offset += 32;
2031
+ data.set(params.notePublicKey, offset); offset += 32;
2032
+ view.setUint32(offset, params.depositVout, true); offset += 4;
2033
+
2034
+ return data;
2035
+ }
2036
+
2037
+ /**
2038
+ * Build utxopia verify_deposit_permissioned instruction data (disc=26).
2039
+ *
2040
+ * `buildVerifyDepositInstructionData`'s payload with a different discriminator
2041
+ * and an auditor ciphertext appended. The tapleaf already tells the pools apart —
2042
+ * each carries its own Ika custody key — but that is not the same as clearing the
2043
+ * pool's policy, which is what a permissioned pool exists for.
2044
+ *
2045
+ * The one-time PolicyApproval is bound to the WHOLE payload, ciphertext included,
2046
+ * so these exact bytes must be the ones approved.
2047
+ */
2048
+ export function buildVerifyDepositPermissionedInstructionData(
2049
+ params: Parameters<typeof buildVerifyDepositInstructionData>[0] & {
2050
+ auditorCiphertext?: Uint8Array;
2051
+ },
2052
+ ): Uint8Array {
2053
+ const base = buildVerifyDepositInstructionData(params);
2054
+ const ciphertext = params.auditorCiphertext ?? new Uint8Array(0);
2055
+
2056
+ const data = new Uint8Array(base.length + ciphertext.length);
2057
+ data.set(base, 0);
2058
+ data.set(ciphertext, base.length);
2059
+ data[0] = PERMISSIONED_DISC.VERIFY_DEPOSIT_PERMISSIONED;
2060
+ return data;
2061
+ }
2062
+
2063
+ // =============================================================================
2064
+ // UTXOpia Set Pool Config (disc=2)
2065
+ // =============================================================================
2066
+
2067
+ /** PoolConfig account discriminator (0x0a) */
2068
+ export const POOL_CONFIG_DISCRIMINATOR = 0x0a;
2069
+
2070
+ /** Serialized PoolConfig account length (bytes) */
2071
+ export const POOL_CONFIG_LEN = 129;
2072
+
2073
+ /** Max pool_script (P2TR scriptPubKey) length */
2074
+ export const POOL_SCRIPT_MAX_LEN = 34;
2075
+
2076
+ /**
2077
+ * Build set_pool_config instruction data (disc=2).
2078
+ *
2079
+ * Strict payload — the program rejects any other shape:
2080
+ * disc(1)
2081
+ * + pool_script_len(1)
2082
+ * + pool_script(N, 1..=34)
2083
+ * + ika_dwallet(32)
2084
+ * + ika_dwallet_xonly_pubkey(32)
2085
+ * + cpi_authority_bump(1)
2086
+ *
2087
+ * `group_pub_key` is no longer part of PoolConfig and must not be sent.
2088
+ */
2089
+ export function buildSetPoolConfigInstructionData(params: {
2090
+ poolScript: Uint8Array;
2091
+ ikaDwallet: Uint8Array;
2092
+ ikaDwalletXonlyPubkey: Uint8Array;
2093
+ cpiAuthorityBump: number;
2094
+ }): Uint8Array {
2095
+ const { poolScript, ikaDwallet, ikaDwalletXonlyPubkey, cpiAuthorityBump } = params;
2096
+ if (poolScript.length < 1 || poolScript.length > POOL_SCRIPT_MAX_LEN) {
2097
+ throw new Error(`poolScript length must be 1..=${POOL_SCRIPT_MAX_LEN}, got ${poolScript.length}`);
2098
+ }
2099
+ if (ikaDwallet.length !== 32) {
2100
+ throw new Error(`ikaDwallet must be 32 bytes, got ${ikaDwallet.length}`);
2101
+ }
2102
+ if (ikaDwalletXonlyPubkey.length !== 32) {
2103
+ throw new Error(`ikaDwalletXonlyPubkey must be 32 bytes, got ${ikaDwalletXonlyPubkey.length}`);
2104
+ }
2105
+
2106
+ const data = new Uint8Array(1 + 1 + poolScript.length + 32 + 32 + 1);
2107
+ let offset = 0;
2108
+ data[offset++] = INSTRUCTION.SET_POOL_CONFIG;
2109
+ data[offset++] = poolScript.length;
2110
+ data.set(poolScript, offset); offset += poolScript.length;
2111
+ data.set(ikaDwallet, offset); offset += 32;
2112
+ data.set(ikaDwalletXonlyPubkey, offset); offset += 32;
2113
+ data[offset++] = cpiAuthorityBump;
2114
+ return data;
2115
+ }
2116
+
2117
+ /** Parsed PoolConfig account (Ika-only, 129 bytes) */
2118
+ export interface ParsedPoolConfig {
2119
+ discriminator: number;
2120
+ poolScriptLen: number;
2121
+ poolScript: Uint8Array;
2122
+ ikaDwallet: Uint8Array;
2123
+ ikaDwalletXonlyPubkey: Uint8Array;
2124
+ cpiAuthorityBump: number;
2125
+ }
2126
+
2127
+ /**
2128
+ * Parse a PoolConfig account.
2129
+ *
2130
+ * Layout (fixed offsets — pool_script is a 34-byte field regardless of len):
2131
+ * disc(1) @0, pool_script_len(1) @1, pool_script(34) @2,
2132
+ * ika_dwallet(32) @36, ika_dwallet_xonly_pubkey(32) @68,
2133
+ * cpi_authority_bump(1) @100, reserved(28) @101
2134
+ */
2135
+ export function parsePoolConfig(data: Uint8Array): ParsedPoolConfig {
2136
+ if (data.length < POOL_CONFIG_LEN) {
2137
+ throw new Error(`PoolConfig account too small: ${data.length} < ${POOL_CONFIG_LEN}`);
2138
+ }
2139
+ if (data[0] !== POOL_CONFIG_DISCRIMINATOR) {
2140
+ throw new Error(`Invalid PoolConfig discriminator: 0x${data[0].toString(16)}`);
2141
+ }
2142
+ const poolScriptLen = data[1];
2143
+ if (poolScriptLen > POOL_SCRIPT_MAX_LEN) {
2144
+ throw new Error(`Invalid pool script length: ${poolScriptLen}`);
2145
+ }
2146
+ return {
2147
+ discriminator: data[0],
2148
+ poolScriptLen,
2149
+ poolScript: data.subarray(2, 2 + poolScriptLen),
2150
+ ikaDwallet: data.subarray(36, 68),
2151
+ ikaDwalletXonlyPubkey: data.subarray(68, 100),
2152
+ cpiAuthorityBump: data[100],
2153
+ };
2154
+ }
2155
+
2156
+ // =============================================================================
2157
+ // Utility Exports
2158
+ // =============================================================================
2159
+
2160
+ /**
2161
+ * Bigint to 32-byte Uint8Array (big-endian)
2162
+ */
2163
+ export function bigintTo32Bytes(value: bigint): Uint8Array {
2164
+ const hex = value.toString(16).padStart(64, "0");
2165
+ const bytes = new Uint8Array(32);
2166
+ for (let i = 0; i < 32; i++) {
2167
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
2168
+ }
2169
+ return bytes;
2170
+ }
2171
+
2172
+ /**
2173
+ * 32-byte Uint8Array to bigint (big-endian)
2174
+ */
2175
+ export function bytes32ToBigint(bytes: Uint8Array): bigint {
2176
+ if (bytes.length !== 32) {
2177
+ throw new Error("Expected 32 bytes");
2178
+ }
2179
+ let hex = "0x";
2180
+ for (let i = 0; i < 32; i++) {
2181
+ hex += bytes[i].toString(16).padStart(2, "0");
2182
+ }
2183
+ return BigInt(hex);
2184
+ }
2185
+
2186
+ // hexToBytes / bytesToHex live in ./crypto (single source); re-exported here
2187
+ // to preserve this module's public surface.
2188
+ export { hexToBytes, bytesToHex } from "./crypto";
2189
+
2190
+ // =============================================================================
2191
+ // Permissioned Pool Builders
2192
+ // =============================================================================
2193
+
2194
+ // ---------------------------------------------------------------------------
2195
+ // initializePermissioned (disc=21)
2196
+ // ---------------------------------------------------------------------------
2197
+
2198
+ /** initializePermissioned instruction options */
2199
+ export interface InitializePermissionedOptions {
2200
+ /** PDA bump for pool state */
2201
+ poolBump: number;
2202
+ /** PDA bump for commitment tree */
2203
+ treeBump: number;
2204
+ /** Deposit fee in basis points (u16 LE) */
2205
+ depositFeeBps: number;
2206
+ /** Withdrawal fee in basis points (u16 LE) */
2207
+ withdrawalFeeBps: number;
2208
+ /** Auditor's Solana pubkey (32 bytes) */
2209
+ auditor: Uint8Array;
2210
+ /** Auditor's viewing public key (32 bytes) */
2211
+ auditorViewingPubkey: Uint8Array;
2212
+ /** Account addresses — same layout as initialize (disc=0) */
2213
+ accounts: {
2214
+ /** 0. pool_state (writable) */
2215
+ poolState: Address;
2216
+ /** 1. commitment_tree (writable) */
2217
+ commitmentTree: Address;
2218
+ /** 2. zkbtc_mint (writable) */
2219
+ zkbtcMint: Address;
2220
+ /** 3. pool_vault (writable) */
2221
+ poolVault: Address;
2222
+ /** 4. deposit_vault (writable) */
2223
+ depositVault: Address;
2224
+ /** 5. authority (signer, writable — pays for storage) */
2225
+ authority: Address;
2226
+ /** 6. system_program (readonly) */
2227
+ systemProgram: Address;
2228
+ };
2229
+ }
2230
+
2231
+ /**
2232
+ * Build initializePermissioned instruction data (disc=21).
2233
+ *
2234
+ * Layout (after disc byte — same as initialize plus two 32-byte fields):
2235
+ * pool_bump(1) + tree_bump(1) + deposit_fee_bps(2 LE) + withdrawal_fee_bps(2 LE)
2236
+ * + auditor(32) + auditor_viewing_pubkey(32)
2237
+ * = 70 bytes of payload; 71 bytes total with disc.
2238
+ */
2239
+ export function buildInitializePermissionedInstructionData(options: {
2240
+ poolBump: number;
2241
+ treeBump: number;
2242
+ depositFeeBps: number;
2243
+ withdrawalFeeBps: number;
2244
+ auditor: Uint8Array;
2245
+ auditorViewingPubkey: Uint8Array;
2246
+ }): Uint8Array {
2247
+ if (options.auditor.length !== 32) {
2248
+ throw new Error(`auditor must be 32 bytes, got ${options.auditor.length}`);
2249
+ }
2250
+ if (options.auditorViewingPubkey.length !== 32) {
2251
+ throw new Error(`auditorViewingPubkey must be 32 bytes, got ${options.auditorViewingPubkey.length}`);
2252
+ }
2253
+
2254
+ // disc(1) + pool_bump(1) + tree_bump(1) + deposit_fee_bps(2) + withdrawal_fee_bps(2) + auditor(32) + auditor_viewing_pubkey(32) = 71
2255
+ const data = new Uint8Array(71);
2256
+ const view = new DataView(data.buffer);
2257
+ let offset = 0;
2258
+
2259
+ data[offset++] = PERMISSIONED_DISC.INITIALIZE_PERMISSIONED; // disc = 21
2260
+ data[offset++] = options.poolBump;
2261
+ data[offset++] = options.treeBump;
2262
+ view.setUint16(offset, options.depositFeeBps, true); offset += 2;
2263
+ view.setUint16(offset, options.withdrawalFeeBps, true); offset += 2;
2264
+ data.set(options.auditor, offset); offset += 32;
2265
+ data.set(options.auditorViewingPubkey, offset);
2266
+
2267
+ return data;
2268
+ }
2269
+
2270
+ /**
2271
+ * Build a complete initializePermissioned instruction (disc=21).
2272
+ *
2273
+ * Initializes a pool in permissioned mode; deposits/shields require auditor co-signing.
2274
+ *
2275
+ * Accounts (identical to initialize, disc=0):
2276
+ * 0. pool_state (writable)
2277
+ * 1. commitment_tree (writable)
2278
+ * 2. zkbtc_mint (writable)
2279
+ * 3. pool_vault (writable)
2280
+ * 4. deposit_vault (writable)
2281
+ * 5. authority (writable signer)
2282
+ * 6. system_program (readonly)
2283
+ */
2284
+ export function buildInitializePermissionedInstruction(
2285
+ options: InitializePermissionedOptions,
2286
+ ): Instruction {
2287
+ const config = getConfig();
2288
+ const data = buildInitializePermissionedInstructionData({
2289
+ poolBump: options.poolBump,
2290
+ treeBump: options.treeBump,
2291
+ depositFeeBps: options.depositFeeBps,
2292
+ withdrawalFeeBps: options.withdrawalFeeBps,
2293
+ auditor: options.auditor,
2294
+ auditorViewingPubkey: options.auditorViewingPubkey,
2295
+ });
2296
+
2297
+ return {
2298
+ programAddress: config.utxopiaProgramId,
2299
+ accounts: [
2300
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2301
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
2302
+ { address: options.accounts.zkbtcMint, role: AccountRole.WRITABLE },
2303
+ { address: options.accounts.poolVault, role: AccountRole.WRITABLE },
2304
+ { address: options.accounts.depositVault, role: AccountRole.WRITABLE },
2305
+ { address: options.accounts.authority, role: AccountRole.WRITABLE_SIGNER },
2306
+ { address: options.accounts.systemProgram, role: AccountRole.READONLY },
2307
+ ],
2308
+ data,
2309
+ };
2310
+ }
2311
+
2312
+ // ---------------------------------------------------------------------------
2313
+ // completeDepositPermissioned (disc=22)
2314
+ // ---------------------------------------------------------------------------
2315
+
2316
+ /** completeDepositPermissioned instruction options */
2317
+ export interface CompleteDepositPermissionedOptions {
2318
+ /** SPV-proven sweep txid (32 bytes, internal byte order) */
2319
+ sweepTxid: Uint8Array;
2320
+ /** Block height containing the verified tx */
2321
+ blockHeight: number;
2322
+ /** Raw sweep tx size in ChadBuffer */
2323
+ sweepTxSize: number;
2324
+ /** Raw deposit tx size in ChadBuffer (0 = direct-to-pool) */
2325
+ depositTxSize: number;
2326
+ /** Deposit txid (32 bytes, internal byte order) */
2327
+ depositTxid: Uint8Array;
2328
+ /**
2329
+ * Auditor ciphertext: either a pre-computed 112-byte `Uint8Array` blob (advanced
2330
+ * / off-chain computed), OR the raw note fields from which the blob is derived
2331
+ * on the fly via {@link buildAuditorCiphertextForNote}.
2332
+ *
2333
+ * If a `Uint8Array` is supplied it is used verbatim (preferred for auditors who
2334
+ * hold the key off-chain). Pass `{ auditorViewingPubKey, tokenId, amount, commitment }`
2335
+ * to have the builder encrypt and embed the ciphertext automatically.
2336
+ */
2337
+ auditorCiphertext: AuditorCiphertextInput;
2338
+ /** Account addresses — same 15 as complete_deposit PLUS auditor at index 15 */
2339
+ accounts: {
2340
+ /** 0. pool_state (writable) */
2341
+ poolState: Address;
2342
+ /** 1. verified_transaction PDA (readonly) */
2343
+ verifiedTransaction: Address;
2344
+ /** 2. light_client (readonly) */
2345
+ lightClient: Address;
2346
+ /** 3. commitment_tree (writable) */
2347
+ commitmentTree: Address;
2348
+ /** 4. tx_buffer / sweep ChadBuffer (readonly) */
2349
+ txBuffer: Address;
2350
+ /** 5. authority (writable signer) */
2351
+ authority: Address;
2352
+ /** 6. system_program (readonly) */
2353
+ systemProgram: Address;
2354
+ /** 7. zkbtc_mint (writable) */
2355
+ zkbtcMint: Address;
2356
+ /** 8. pool_vault (writable) */
2357
+ poolVault: Address;
2358
+ /** 9. token_program (readonly) */
2359
+ tokenProgram: Address;
2360
+ /** 10. deposit_tx_buffer (readonly) */
2361
+ depositTxBuffer: Address;
2362
+ /** 11. deposit_receipt PDA (writable) */
2363
+ depositReceipt: Address;
2364
+ /** 12. utxo_record PDA (writable) */
2365
+ utxoRecord: Address;
2366
+ /** 13. token_config PDA (writable) */
2367
+ tokenConfig: Address;
2368
+ /** 14. pool_config PDA (readonly) */
2369
+ poolConfig: Address;
2370
+ /** HeightIndex PDA for the VerifiedTransaction's block —
2371
+ * `deriveHeightIndexPDA(blockHeight, config.btcLightClientProgramId)`.
2372
+ *
2373
+ * REQUIRED. The program re-checks that the proof's block is still the canonical one at
2374
+ * that height before it settles (audit_1 F-BTC-04): a VerifiedTransaction records a merkle
2375
+ * proof that was valid once and is never invalidated, and the confirmation count is taken
2376
+ * against a tip that only grows, so neither notices a reorg. Omitting this fails with
2377
+ * InvalidSpvProof — the program locates the account by address, so its position in the
2378
+ * list does not matter, but its absence is an error rather than a skipped check.
2379
+ */
2380
+ heightIndex: Address;
2381
+ /** 15. one-time PolicyApproval (writable) */
2382
+ policyApproval: Address;
2383
+ };
2384
+ }
2385
+
2386
+ /**
2387
+ * Build completeDepositPermissioned instruction data (disc=22).
2388
+ *
2389
+ * Layout:
2390
+ * disc(1) + CompleteDepositData fixed header (80 bytes) + auditorCiphertext (variable)
2391
+ *
2392
+ * The 80-byte header is identical to complete_deposit (disc=11).
2393
+ */
2394
+ export function buildCompleteDepositPermissionedInstructionData(options: {
2395
+ sweepTxid: Uint8Array;
2396
+ blockHeight: number;
2397
+ sweepTxSize: number;
2398
+ depositTxSize: number;
2399
+ depositTxid: Uint8Array;
2400
+ auditorCiphertext: AuditorCiphertextInput;
2401
+ }): Uint8Array {
2402
+ // disc(1) + sweep_txid(32) + block_height(8) + sweep_tx_size(4) + deposit_tx_size(4) + deposit_txid(32) + auditorCiphertext(variable)
2403
+ const auditorCiphertext = resolveAuditorCiphertext(options.auditorCiphertext);
2404
+ const headerSize = 1 + 32 + 8 + 4 + 4 + 32; // 81 bytes
2405
+ const data = new Uint8Array(headerSize + auditorCiphertext.length);
2406
+ const view = new DataView(data.buffer);
2407
+ let offset = 0;
2408
+
2409
+ data[offset++] = PERMISSIONED_DISC.COMPLETE_DEPOSIT_PERMISSIONED; // disc = 22
2410
+ data.set(options.sweepTxid, offset); offset += 32;
2411
+ view.setBigUint64(offset, BigInt(options.blockHeight), true); offset += 8;
2412
+ view.setUint32(offset, options.sweepTxSize, true); offset += 4;
2413
+ view.setUint32(offset, options.depositTxSize, true); offset += 4;
2414
+ data.set(options.depositTxid, offset); offset += 32;
2415
+ if (auditorCiphertext.length > 0) {
2416
+ data.set(auditorCiphertext, offset);
2417
+ }
2418
+
2419
+ return data;
2420
+ }
2421
+
2422
+ /**
2423
+ * Build a complete completeDepositPermissioned instruction (disc=22).
2424
+ *
2425
+ * Same accounts as complete_deposit (disc=11), plus a one-time PolicyApproval
2426
+ * appended at account index 15.
2427
+ *
2428
+ * Accounts:
2429
+ * 0. pool_state (writable)
2430
+ * 1. verified_transaction(readonly)
2431
+ * 2. light_client (readonly)
2432
+ * 3. commitment_tree (writable)
2433
+ * 4. tx_buffer (readonly)
2434
+ * 5. authority (writable signer)
2435
+ * 6. system_program (readonly)
2436
+ * 7. zkbtc_mint (writable)
2437
+ * 8. pool_vault (writable)
2438
+ * 9. token_program (readonly)
2439
+ * 10. deposit_tx_buffer (readonly)
2440
+ * 11. deposit_receipt (writable)
2441
+ * 12. utxo_record (writable)
2442
+ * 13. token_config (writable)
2443
+ * 14. pool_config (readonly)
2444
+ * 15. policy_approval (writable)
2445
+ * 16. policy_program (readonly)
2446
+ * 17. height_index (readonly) — canonicality re-check, located by address
2447
+ */
2448
+ export function buildCompleteDepositPermissionedInstruction(
2449
+ options: CompleteDepositPermissionedOptions,
2450
+ ): Instruction {
2451
+ const config = getConfig();
2452
+ const data = buildCompleteDepositPermissionedInstructionData({
2453
+ sweepTxid: options.sweepTxid,
2454
+ blockHeight: options.blockHeight,
2455
+ sweepTxSize: options.sweepTxSize,
2456
+ depositTxSize: options.depositTxSize,
2457
+ depositTxid: options.depositTxid,
2458
+ auditorCiphertext: options.auditorCiphertext,
2459
+ });
2460
+
2461
+ return {
2462
+ programAddress: config.utxopiaProgramId,
2463
+ accounts: [
2464
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2465
+ { address: options.accounts.verifiedTransaction, role: AccountRole.READONLY },
2466
+ { address: options.accounts.lightClient, role: AccountRole.READONLY },
2467
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
2468
+ { address: options.accounts.txBuffer, role: AccountRole.READONLY },
2469
+ { address: options.accounts.authority, role: AccountRole.WRITABLE_SIGNER },
2470
+ { address: options.accounts.systemProgram, role: AccountRole.READONLY },
2471
+ { address: options.accounts.zkbtcMint, role: AccountRole.WRITABLE },
2472
+ { address: options.accounts.poolVault, role: AccountRole.WRITABLE },
2473
+ { address: options.accounts.tokenProgram, role: AccountRole.READONLY },
2474
+ { address: options.accounts.depositTxBuffer, role: AccountRole.READONLY },
2475
+ { address: options.accounts.depositReceipt, role: AccountRole.WRITABLE },
2476
+ { address: options.accounts.utxoRecord, role: AccountRole.WRITABLE },
2477
+ { address: options.accounts.tokenConfig, role: AccountRole.WRITABLE },
2478
+ { address: options.accounts.poolConfig, role: AccountRole.READONLY },
2479
+ { address: options.accounts.policyApproval, role: AccountRole.WRITABLE },
2480
+ { address: config.policyProgramId ?? config.utxopiaProgramId, role: AccountRole.READONLY },
2481
+ { address: options.accounts.heightIndex, role: AccountRole.READONLY },
2482
+ ],
2483
+ data,
2484
+ };
2485
+ }
2486
+
2487
+ // ---------------------------------------------------------------------------
2488
+ // shieldPermissioned (disc=23)
2489
+ // ---------------------------------------------------------------------------
2490
+
2491
+ /** shieldPermissioned instruction options */
2492
+ export interface ShieldPermissionedInstructionOptions {
2493
+ /** Amount to shield (in token's smallest unit) */
2494
+ amount: bigint;
2495
+ /** NPK bytes (32) — recipient's note public key */
2496
+ npk: Uint8Array;
2497
+ /** Ephemeral public key (32) — for stealth address derivation */
2498
+ ephemeralPub: Uint8Array;
2499
+ /**
2500
+ * Auditor ciphertext: either a pre-computed 112-byte `Uint8Array` blob (advanced
2501
+ * / off-chain computed), OR the raw note fields from which the blob is derived
2502
+ * on the fly via {@link buildAuditorCiphertextForNote}.
2503
+ *
2504
+ * If a `Uint8Array` is supplied it is used verbatim (preferred for auditors who
2505
+ * hold the key off-chain). Pass `{ auditorViewingPubKey, tokenId, amount, commitment }`
2506
+ * to have the builder encrypt and embed the ciphertext automatically.
2507
+ */
2508
+ auditorCiphertext: AuditorCiphertextInput;
2509
+ /** Account addresses — same 7 as shield (disc=12) plus PolicyApproval at index 7 */
2510
+ accounts: {
2511
+ /** 0. user (writable signer) */
2512
+ user: Address;
2513
+ /** 1. user_token_account (writable) */
2514
+ userTokenAccount: Address;
2515
+ /** 2. pool_state (readonly) */
2516
+ poolState: Address;
2517
+ /** 3. token_config (writable) */
2518
+ tokenConfig: Address;
2519
+ /** 4. vault (writable) */
2520
+ vault: Address;
2521
+ /** 5. commitment_tree (writable) */
2522
+ commitmentTree: Address;
2523
+ /** 6. token_program (readonly) */
2524
+ tokenProgram: Address;
2525
+ /** 7. one-time PolicyApproval (writable) */
2526
+ policyApproval: Address;
2527
+ /**
2528
+ * 9. ExitDestination PDA registered for `user`.
2529
+ *
2530
+ * Value cannot enter without a way back out: the program refuses a
2531
+ * depositor who has no registered exit, so they can always ragequit to
2532
+ * their own wallet without the auditor. Derive with
2533
+ * {@link deriveExitDestinationPDA} using `EXIT_KIND_SOLANA_OWNER` and the
2534
+ * depositor's address.
2535
+ */
2536
+ exitDestination: Address;
2537
+ };
2538
+ }
2539
+
2540
+ /**
2541
+ * Build shieldPermissioned instruction data (disc=23).
2542
+ *
2543
+ * Layout:
2544
+ * disc(1) + shield header (72 bytes) + auditorCiphertext (variable)
2545
+ *
2546
+ * The 72-byte header is identical to shield (disc=12):
2547
+ * amount(8 LE) + npk(32) + ephemeral_pub(32)
2548
+ */
2549
+ export function buildShieldPermissionedInstructionData(options: {
2550
+ amount: bigint;
2551
+ npk: Uint8Array;
2552
+ ephemeralPub: Uint8Array;
2553
+ auditorCiphertext: AuditorCiphertextInput;
2554
+ }): Uint8Array {
2555
+ // disc(1) + amount(8) + npk(32) + ephemeral_pub(32) + auditorCiphertext(variable)
2556
+ const auditorCiphertext = resolveAuditorCiphertext(options.auditorCiphertext);
2557
+ const headerSize = 1 + 8 + 32 + 32; // 73 bytes
2558
+ const data = new Uint8Array(headerSize + auditorCiphertext.length);
2559
+ const view = new DataView(data.buffer);
2560
+ let offset = 0;
2561
+
2562
+ data[offset++] = PERMISSIONED_DISC.SHIELD_PERMISSIONED; // disc = 23
2563
+ view.setBigUint64(offset, options.amount, true); offset += 8;
2564
+ data.set(options.npk.slice(0, 32), offset); offset += 32;
2565
+ data.set(options.ephemeralPub.slice(0, 32), offset); offset += 32;
2566
+ if (auditorCiphertext.length > 0) {
2567
+ data.set(auditorCiphertext, offset);
2568
+ }
2569
+
2570
+ return data;
2571
+ }
2572
+
2573
+ /**
2574
+ * Build a complete shieldPermissioned instruction (disc=23).
2575
+ *
2576
+ * Same accounts as shield (disc=12), plus three appended.
2577
+ *
2578
+ * Accounts:
2579
+ * 0. user (writable signer)
2580
+ * 1. user_token_account(writable)
2581
+ * 2. pool_state (writable)
2582
+ * 3. token_config (writable)
2583
+ * 4. vault (writable)
2584
+ * 5. commitment_tree (writable)
2585
+ * 6. token_program (readonly)
2586
+ * 7. policy_approval (writable)
2587
+ * 8. policy_program (readonly)
2588
+ * 9. exit_destination (readonly) — the depositor's registered exit
2589
+ */
2590
+ export function buildShieldPermissionedInstruction(
2591
+ options: ShieldPermissionedInstructionOptions,
2592
+ ): Instruction {
2593
+ const config = getConfig();
2594
+ const data = buildShieldPermissionedInstructionData({
2595
+ amount: options.amount,
2596
+ npk: options.npk,
2597
+ ephemeralPub: options.ephemeralPub,
2598
+ auditorCiphertext: options.auditorCiphertext,
2599
+ });
2600
+
2601
+ return {
2602
+ programAddress: config.utxopiaProgramId,
2603
+ accounts: [
2604
+ { address: options.accounts.user, role: AccountRole.WRITABLE_SIGNER },
2605
+ { address: options.accounts.userTokenAccount, role: AccountRole.WRITABLE },
2606
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2607
+ { address: options.accounts.tokenConfig, role: AccountRole.WRITABLE },
2608
+ { address: options.accounts.vault, role: AccountRole.WRITABLE },
2609
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
2610
+ { address: options.accounts.tokenProgram, role: AccountRole.READONLY },
2611
+ { address: options.accounts.policyApproval, role: AccountRole.WRITABLE },
2612
+ { address: config.policyProgramId ?? config.utxopiaProgramId, role: AccountRole.READONLY },
2613
+ { address: options.accounts.exitDestination, role: AccountRole.READONLY },
2614
+ ],
2615
+ data,
2616
+ };
2617
+ }
2618
+
2619
+ // ---------------------------------------------------------------------------
2620
+ // registerExitDestination (disc=39)
2621
+ // ---------------------------------------------------------------------------
2622
+
2623
+ /**
2624
+ * Build a registerExitDestination instruction (disc=39) — auditor-only.
2625
+ *
2626
+ * Adds one entry to a permissioned pool's append-only exit registry. Entries can
2627
+ * be added but never removed: a removable entry would hand the auditor back the
2628
+ * ability to prevent a withdrawal, which is the one power the design withholds.
2629
+ *
2630
+ * Register a depositor's address BEFORE their first `shieldPermissioned` — the
2631
+ * program refuses a depositor who has no exit.
2632
+ *
2633
+ * Data: kind(1) + key(32)
2634
+ * Accounts: 0. auditor (writable signer, pays rent)
2635
+ * 1. pool_state (readonly)
2636
+ * 2. exit_destination PDA (writable, uninitialized)
2637
+ * 3. system_program (readonly)
2638
+ */
2639
+ /**
2640
+ * Build registerExitDestination instruction data (disc=39).
2641
+ *
2642
+ * Layout: disc(1) + kind(1) + key(32)
2643
+ */
2644
+ export function buildRegisterExitDestinationInstructionData(options: {
2645
+ kind: number;
2646
+ key: Uint8Array;
2647
+ }): Uint8Array {
2648
+ if (options.key.length !== 32) {
2649
+ throw new Error("exit destination key must be 32 bytes");
2650
+ }
2651
+ const data = new Uint8Array(34);
2652
+ data[0] = PERMISSIONED_DISC.REGISTER_EXIT_DESTINATION;
2653
+ data[1] = options.kind;
2654
+ data.set(options.key, 2);
2655
+ return data;
2656
+ }
2657
+
2658
+ export function buildRegisterExitDestinationInstruction(options: {
2659
+ /** `EXIT_KIND_SOLANA_OWNER` or `EXIT_KIND_BTC_SCRIPT`. */
2660
+ kind: number;
2661
+ /** Recipient token account owner, or sha256(btcScript). */
2662
+ key: Uint8Array;
2663
+ accounts: {
2664
+ auditor: Address;
2665
+ poolState: Address;
2666
+ exitDestination: Address;
2667
+ };
2668
+ }): Instruction {
2669
+ const config = getConfig();
2670
+ const data = buildRegisterExitDestinationInstructionData(options);
2671
+
2672
+ return {
2673
+ programAddress: config.utxopiaProgramId,
2674
+ accounts: [
2675
+ { address: options.accounts.auditor, role: AccountRole.WRITABLE_SIGNER },
2676
+ { address: options.accounts.poolState, role: AccountRole.READONLY },
2677
+ { address: options.accounts.exitDestination, role: AccountRole.WRITABLE },
2678
+ { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
2679
+ ],
2680
+ data,
2681
+ };
2682
+ }
2683
+
2684
+ // ---------------------------------------------------------------------------
2685
+ // setAuditorFrozen (disc=28)
2686
+ // ---------------------------------------------------------------------------
2687
+
2688
+ /** setAuditorFrozen instruction options */
2689
+ export interface SetAuditorFrozenOptions {
2690
+ /** true = freeze the auditor role; false = un-freeze */
2691
+ frozen: boolean;
2692
+ accounts: {
2693
+ /** 0. pool_state (writable) */
2694
+ poolState: Address;
2695
+ /** 1. auditor (signer) */
2696
+ auditor: Address;
2697
+ };
2698
+ }
2699
+
2700
+ /**
2701
+ * Build setAuditorFrozen instruction data (disc=28).
2702
+ *
2703
+ * Layout: disc(1) + frozen(1) — frozen byte: 0 = not frozen, 1 = frozen.
2704
+ */
2705
+ export function buildSetAuditorFrozenInstructionData(frozen: boolean): Uint8Array {
2706
+ return new Uint8Array([INSTRUCTION.SET_AUDITOR_FROZEN, frozen ? 1 : 0]);
2707
+ }
2708
+
2709
+ /**
2710
+ * Build a complete setAuditorFrozen instruction (disc=28).
2711
+ *
2712
+ * Accounts:
2713
+ * 0. pool_state (writable)
2714
+ * 1. auditor (signer)
2715
+ */
2716
+ export function buildSetAuditorFrozenInstruction(options: SetAuditorFrozenOptions): Instruction {
2717
+ const config = getConfig();
2718
+ return {
2719
+ programAddress: config.utxopiaProgramId,
2720
+ accounts: [
2721
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2722
+ { address: options.accounts.auditor, role: AccountRole.READONLY_SIGNER },
2723
+ ],
2724
+ data: buildSetAuditorFrozenInstructionData(options.frozen),
2725
+ };
2726
+ }
2727
+
2728
+ // ---------------------------------------------------------------------------
2729
+ // setAuditorViewingPubkey (disc=29)
2730
+ // ---------------------------------------------------------------------------
2731
+
2732
+ /** setAuditorViewingPubkey instruction options */
2733
+ export interface SetAuditorViewingPubkeyOptions {
2734
+ /** New 32-byte viewing pubkey for the auditor */
2735
+ viewingPubkey: Uint8Array;
2736
+ accounts: {
2737
+ /** 0. pool_state (writable) */
2738
+ poolState: Address;
2739
+ /** 1. auditor (signer) */
2740
+ auditor: Address;
2741
+ };
2742
+ }
2743
+
2744
+ /**
2745
+ * Build setAuditorViewingPubkey instruction data (disc=29).
2746
+ *
2747
+ * Layout: disc(1) + viewing_pubkey(32) = 33 bytes.
2748
+ */
2749
+ export function buildSetAuditorViewingPubkeyInstructionData(viewingPubkey: Uint8Array): Uint8Array {
2750
+ if (viewingPubkey.length !== 32) {
2751
+ throw new Error(`viewingPubkey must be 32 bytes, got ${viewingPubkey.length}`);
2752
+ }
2753
+ const data = new Uint8Array(33);
2754
+ data[0] = INSTRUCTION.SET_AUDITOR_VIEWING_PUBKEY;
2755
+ data.set(viewingPubkey, 1);
2756
+ return data;
2757
+ }
2758
+
2759
+ /**
2760
+ * Build a complete setAuditorViewingPubkey instruction (disc=29).
2761
+ *
2762
+ * Accounts:
2763
+ * 0. pool_state (writable)
2764
+ * 1. auditor (signer)
2765
+ */
2766
+ export function buildSetAuditorViewingPubkeyInstruction(
2767
+ options: SetAuditorViewingPubkeyOptions,
2768
+ ): Instruction {
2769
+ const config = getConfig();
2770
+ return {
2771
+ programAddress: config.utxopiaProgramId,
2772
+ accounts: [
2773
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2774
+ { address: options.accounts.auditor, role: AccountRole.READONLY_SIGNER },
2775
+ ],
2776
+ data: buildSetAuditorViewingPubkeyInstructionData(options.viewingPubkey),
2777
+ };
2778
+ }
2779
+
2780
+ // ---------------------------------------------------------------------------
2781
+ // rotateAuditor (disc=35)
2782
+ // ---------------------------------------------------------------------------
2783
+
2784
+ export interface RotateAuditorOptions {
2785
+ auditor: Uint8Array;
2786
+ viewingPubkey: Uint8Array;
2787
+ accounts: {
2788
+ poolState: Address;
2789
+ authority: Address;
2790
+ };
2791
+ }
2792
+
2793
+ export function buildRotateAuditorInstructionData(
2794
+ auditor: Uint8Array,
2795
+ viewingPubkey: Uint8Array,
2796
+ ): Uint8Array {
2797
+ if (auditor.length !== 32 || auditor.every((byte) => byte === 0)) {
2798
+ throw new Error("auditor must be a nonzero 32-byte public key");
2799
+ }
2800
+ if (viewingPubkey.length !== 32 || viewingPubkey.every((byte) => byte === 0)) {
2801
+ throw new Error("viewingPubkey must be a nonzero 32-byte public key");
2802
+ }
2803
+ const data = new Uint8Array(65);
2804
+ data[0] = INSTRUCTION.ROTATE_AUDITOR;
2805
+ data.set(auditor, 1);
2806
+ data.set(viewingPubkey, 33);
2807
+ return data;
2808
+ }
2809
+
2810
+ export function buildRotateAuditorInstruction(options: RotateAuditorOptions): Instruction {
2811
+ const config = getConfig();
2812
+ return {
2813
+ programAddress: config.utxopiaProgramId,
2814
+ accounts: [
2815
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2816
+ { address: options.accounts.authority, role: AccountRole.READONLY_SIGNER },
2817
+ ],
2818
+ data: buildRotateAuditorInstructionData(options.auditor, options.viewingPubkey),
2819
+ };
2820
+ }