@utxopia/sdk 0.1.0-alpha.7 → 0.1.0-alpha.8

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.
@@ -189,7 +189,16 @@ export class CommitmentTreeIndex {
189
189
  if (leafIndex >= MAX_LEAVES) {
190
190
  throw new Error("Tree is full");
191
191
  }
192
- // Store in map for lookup
192
+ // Store in map for lookup.
193
+ //
194
+ // Test-only helper, and this map is why: a commitment is NOT unique in the
195
+ // tree. Paying the same BTC deposit address twice for the same amount yields
196
+ // byte-identical commitments (complete_deposit hashes note_public_key,
197
+ // token_id and shielded_amount), so a second add overwrites the first and
198
+ // getMerkleProof then returns only the later leaf. Production never takes
199
+ // this path — announcements dedupe on leafIndex and proofs come from
200
+ // /api/tree/proof/:leaf_index — but do not promote this class to a wallet
201
+ // path without keying on leafIndex instead.
193
202
  const commitmentHex = commitment.toString(16).padStart(64, "0");
194
203
  this.commitments.set(commitmentHex, { index: leafIndex, amount });
195
204
  // Add to leaves array
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export { formatSpendDoc, renderSpendDoc, SpendDocMismatch, type SpendDoc, type S
43
43
  export { getConfig, setConfig, createConfig, initConfig, DEVNET_CONFIG, DEVNET_REGTEST_CONFIG, MAINNET_CONFIG, LOCALNET_CONFIG, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, ATA_PROGRAM_ID, UTXOPIA_POLICY_PROGRAM_ID, SDK_VERSION, DEPLOYMENT_INFO, JOINSPLIT_TREE_DEPTH, type NetworkConfig, type NetworkType, } from "./config";
44
44
  export { POOL_STATE_DISCRIMINATOR, POOL_STATE_LEN, POOL_STATE_OFFSETS, POOL_FLAG, parsePoolState, parsePoolFees, BPS_DENOMINATOR, computeBpsFee, feeShareBps, } from "./pool-state";
45
45
  export type { PoolState, PoolFees } from "./pool-state";
46
- export { PDA_SEEDS, poolStateSeeds, commitmentTreeSeeds, tokenConfigSeeds, poolConfigSeeds, nullifierRecordSeeds, redemptionRequestSeeds, vkRegistrySeeds, depositReceiptSeeds, policyApprovalSeeds, exitDestinationSeeds, lightClientSeeds, blockHeaderSeeds, heightIndexSeeds, verifiedTransactionSeeds, derivePoolStatePDA, deriveCommitmentTreePDA, deriveNullifierRecordPDA, derivePolicyApprovalPDA, deriveExitDestinationPDA, EXIT_KIND_SOLANA_OWNER, EXIT_KIND_BTC_SCRIPT, deriveLightClientPDA, deriveBlockHeaderPDA, deriveHeightIndexPDA, deriveVkRegistryPDA, deriveRedemptionRequestPDA, deriveTokenConfigPDA, derivePoolConfigPDA, deriveDepositReceiptPDA, commitmentToBytes, } from "./pda";
46
+ export { PDA_SEEDS, poolStateSeeds, commitmentTreeSeeds, tokenConfigSeeds, poolConfigSeeds, nullifierRecordSeeds, redemptionRequestSeeds, vkRegistrySeeds, depositReceiptSeeds, policyApprovalSeeds, exitDestinationSeeds, lightClientSeeds, blockHeaderSeeds, heightIndexSeeds, verifiedTransactionSeeds, derivePoolStatePDA, deriveCommitmentTreePDA, deriveNullifierRecordPDA, deriveQueuedLeafPDA, queuedLeafSeeds, derivePolicyApprovalPDA, deriveExitDestinationPDA, EXIT_KIND_SOLANA_OWNER, EXIT_KIND_BTC_SCRIPT, deriveLightClientPDA, deriveBlockHeaderPDA, deriveHeightIndexPDA, deriveVkRegistryPDA, deriveRedemptionRequestPDA, deriveTokenConfigPDA, derivePoolConfigPDA, deriveDepositReceiptPDA, commitmentToBytes, } from "./pda";
47
47
  export { createStealthDeposit, createStealthDepositWithKeys, createStealthOutput, createStealthOutputWithKeys, createStealthOutputForCommitment, packStealthOutputForCircuit, scanAnnouncements, scanAnnouncementsViewOnly, scanAnnouncementsViewOnlyMulti, exportViewOnlyKeys, encodeViewOnlyKeys, decodeViewOnlyKeys, prepareClaimInputs, scanUnifiedNotes, scanUnifiedNotesMulti, encryptAmount, decryptAmount, computeNullifierHashForNote, computeNullifierBytes, parseAnnouncementsFromHex, createDepositFromConfig, createDirectVaultDeposit, createTweakDeposit, depositEphemeralKeyPair, depositViewingNode, outgoingViewingNode, outgoingEphemeralKeyPair, findNextSendIndex, isDepositForViewer, isDepositForViewerHex, ANNOUNCEMENT_TYPE_DEPOSIT, ANNOUNCEMENT_TYPE_TRANSFER, type StealthDeposit, type StealthOutputData, type StealthOutputWithKeys, type CircuitStealthOutput, type ScannedNote, type ClaimInputs as StealthClaimInputs, type OnChainStealthAnnouncement, type ConnectionAdapter, type ViewOnlyKeys, type ViewOnlyScannedNote, createNonInteractiveDeposit, pickIkaCustodyKey, type NonInteractiveDepositResult, type TweakDepositResult, type DepositRecoveryMaterial, type OutgoingRecoveryMaterial, type NonInteractiveDepositWithRefundResult, } from "./stealth";
48
48
  export { buildDepositPsbt, estimateDepositFee, fetchUtxos, selectUtxos, type BuildDepositPsbtParams, type BuildDepositPsbtResult, type UtxoDescriptor, } from "./psbt";
49
49
  export { EsploraClient, esploraTestnet, esploraMainnet, type EsploraTransaction, type EsploraVin, type EsploraVout, type EsploraStatus, type EsploraAddressInfo, type EsploraUtxo, type EsploraMerkleProof, type EsploraNetwork, } from "./core/esplora";
@@ -53,7 +53,7 @@ export { setDebug } from "./logger";
53
53
  export { createFetchConnectionAdapter, createConnectionAdapterFromWeb3, createConnectionAdapterFromKit, getConnectionAdapter, clearConnectionAdapterCache, type RpcConfig, type Web3Connection, type KitRpc, } from "./solana/connection";
54
54
  export { resolveSnsName, resolveStealthName, parseSnsStealthData, isSnsStealthAddress, isAuditorDisclosable, SnsComplianceFlags, SNS_COMPLIANCE_AUDITOR_OFFSET, SNS_COMPLIANCE_AUDITOR_BYTES, deriveParentDomainKey, SNS_STEALTH_DATA_SIZE, type SnsStealthAddress, } from "./sns-resolver";
55
55
  export { COMMITMENT_TREE_DISCRIMINATOR, parseCommitmentTreeData, isValidRoot, fetchCommitmentTree, getCommitmentIndex, saveCommitmentIndex, CommitmentTreeIndex, buildCommitmentTreeFromChain, fetchLeafIndexForCommitment, fetchMerkleProofForCommitment, getMerkleProofFromTree, type CommitmentTreeState, type RpcClient, type OnChainMerkleProof, } from "./commitment-tree";
56
- export { INSTRUCTION_DISCRIMINATORS, buildShieldInstructionData, buildShieldInstruction, type ShieldInstructionOptions, buildApproveRedemptionSigningInstructionData, buildApproveRedemptionSigningInstruction, buildCancelRedemptionInstructionData, buildCancelRedemptionInstruction, type CancelRedemptionInstructionOptions, bigintTo32Bytes, bytes32ToBigint, buildTransactInstructionData, buildTransactInstruction, buildRedeemInstructionData, buildUnshieldInstructionData, buildUnshieldInstruction, buildProposePoolUpdateInstructionData, buildProposePoolUpdateInstruction, buildExecutePoolUpdateInstructionData, buildExecutePoolUpdateInstruction, buildCancelPoolUpdateInstructionData, buildCancelPoolUpdateInstruction, buildRotateTreeInstructionData, buildRotateTreeInstruction, type RotateTreeOptions, buildMagicBlockDelegateInstructionData, buildMagicBlockDelegateInstruction, buildMagicBlockCommitInstructionData, buildMagicBlockCommitInstruction, buildMagicBlockPerPermissionInstructionData, buildMagicBlockPerPermissionInstruction, buildPolicyRequestHash, buildPolicyIntentParts, buildRegisterExitDestinationInstruction, buildRegisterExitDestinationInstructionData, MAX_POLICY_INTENT_PARTS, buildInitializePolicyApprovalInstructionData, buildInitializePolicyApprovalInstruction, buildPolicyApprovalDecisionInstruction, buildPolicyApprovalCommitInstruction, buildCompleteDepositPermissionedInstructionData, buildCompleteDepositPermissionedInstruction, buildShieldPermissionedInstructionData, buildShieldPermissionedInstruction, buildRotateAuditorInstructionData, buildRotateAuditorInstruction, type MagicBlockDelegateTarget, type MagicBlockDelegateInstructionOptions, type MagicBlockCommitInstructionOptions, type MagicBlockPerPermissionOperation, type MagicBlockPerPermissionMember, type MagicBlockPerPermissionInstructionOptions, type PolicyApprovalDecision, type InitializePolicyApprovalOptions, type CompleteDepositPermissionedOptions, type ShieldPermissionedInstructionOptions, type RotateAuditorOptions, buildVerifyTransactionInstructionData, buildCompleteDepositInstructionData, buildVerifyDepositInstructionData, buildVerifyDepositPermissionedInstructionData, buildSetPoolConfigInstructionData, parsePoolConfig, type ParsedPoolConfig, POOL_CONFIG_DISCRIMINATOR, POOL_CONFIG_LEN, POOL_SCRIPT_MAX_LEN, type Instruction, type ApproveRedemptionSigningInstructionOptions, type TransactInstructionOptions, type UnshieldInstructionOptions, type ProposePoolUpdateOptions, type ExecutePoolUpdateOptions, type CancelPoolUpdateOptions, } from "./instructions";
56
+ export { INSTRUCTION_DISCRIMINATORS, buildShieldInstructionData, buildShieldInstruction, type ShieldInstructionOptions, buildApproveRedemptionSigningInstructionData, buildApproveRedemptionSigningInstruction, buildCancelRedemptionInstructionData, buildCancelRedemptionInstruction, type CancelRedemptionInstructionOptions, bigintTo32Bytes, bytes32ToBigint, JSFLAGS, joinSplitFlags, buildMergeQueuedLeavesInstruction, MAX_MERGE_LEAVES, buildTransactInstructionData, buildTransactInstruction, buildRedeemInstructionData, buildUnshieldInstructionData, buildUnshieldInstruction, buildProposePoolUpdateInstructionData, buildProposePoolUpdateInstruction, buildExecutePoolUpdateInstructionData, buildExecutePoolUpdateInstruction, buildCancelPoolUpdateInstructionData, buildCancelPoolUpdateInstruction, buildRotateTreeInstructionData, buildRotateTreeInstruction, type RotateTreeOptions, buildMagicBlockDelegateInstructionData, buildMagicBlockDelegateInstruction, buildMagicBlockCommitInstructionData, buildMagicBlockCommitInstruction, buildMagicBlockPerPermissionInstructionData, buildMagicBlockPerPermissionInstruction, buildPolicyRequestHash, buildPolicyIntentParts, buildRegisterExitDestinationInstruction, buildRegisterExitDestinationInstructionData, MAX_POLICY_INTENT_PARTS, buildInitializePolicyApprovalInstructionData, buildInitializePolicyApprovalInstruction, buildPolicyApprovalDecisionInstruction, buildPolicyApprovalCommitInstruction, buildCompleteDepositPermissionedInstructionData, buildCompleteDepositPermissionedInstruction, buildShieldPermissionedInstructionData, buildShieldPermissionedInstruction, buildRotateAuditorInstructionData, buildRotateAuditorInstruction, type MagicBlockDelegateTarget, type JoinSplitTailOptions, type PolicyTailKind, type MagicBlockDelegateInstructionOptions, type MagicBlockCommitInstructionOptions, type MagicBlockPerPermissionOperation, type MagicBlockPerPermissionMember, type MagicBlockPerPermissionInstructionOptions, type PolicyApprovalDecision, type InitializePolicyApprovalOptions, type CompleteDepositPermissionedOptions, type ShieldPermissionedInstructionOptions, type RotateAuditorOptions, buildVerifyTransactionInstructionData, buildCompleteDepositInstructionData, buildVerifyDepositInstructionData, buildVerifyDepositPermissionedInstructionData, buildSetPoolConfigInstructionData, parsePoolConfig, type ParsedPoolConfig, POOL_CONFIG_DISCRIMINATOR, POOL_CONFIG_LEN, POOL_SCRIPT_MAX_LEN, type Instruction, type ApproveRedemptionSigningInstructionOptions, type TransactInstructionOptions, type UnshieldInstructionOptions, type ProposePoolUpdateOptions, type ExecutePoolUpdateOptions, type CancelPoolUpdateOptions, } from "./instructions";
57
57
  export { VK_REGISTRY_DISCRIMINATOR, VK_REGISTRY_LEN, MAX_IC_POINTS, MAX_SAFE_JOINSPLIT_SIZE, INIT_VK_REGISTRY_DISCRIMINATOR, UPDATE_VK_REGISTRY_DISCRIMINATOR, joinSplitNumPublicInputs, computeVkHash, vkeyJsonToVkMaterial, buildVkRegistryData, parseVkRegistry, assertVkRegistryForShape, assertVkeyMatchesRegistry, isVkRegistryReady, type JoinSplitVkMaterial, type SnarkjsVkeyJson, type ParsedVkRegistry, } from "./vk-registry";
58
58
  export { fetchExplorerDeposits, fetchExplorerTransfers, fetchExplorerRedemptions, parseNullifierRecord, parseRedemptionRequest, NULLIFIER_RECORD_SIZE, REDEMPTION_REQUEST_SIZE, NULLIFIER_RECORD_DISCRIMINATOR, REDEMPTION_REQUEST_DISCRIMINATOR, OPERATION_TYPE_LABELS, type ExplorerDeposit, type ExplorerTransferEvent, type ExplorerRedemption, type IndexerLeaf, } from "./explorer";
59
59
  export { parseProgramEvents, parseNullifierSpentEvent, parseStealthAnnouncementEvent, parseSenderMemoEvent, parseBtcOriginAttestationEvent, parseAuditorCiphertextEvent, EVENT_NULLIFIER_SPENT, EVENT_STEALTH_ANNOUNCEMENT, EVENT_NULLIFIERS_BATCH, EVENT_ANNOUNCEMENTS_BATCH, EVENT_SENDER_MEMO, EVENT_BTC_ORIGIN_ATTESTATION, EVENT_AUDITOR_CIPHERTEXT, type NullifierSpentEvent, type StealthAnnouncementEvent, type SenderMemoEvent, type BtcOriginAttestationEvent, type AuditorCiphertextEvent, type ProgramEvent, } from "./events";
package/dist/index.js CHANGED
@@ -123,7 +123,7 @@ export { PDA_SEEDS,
123
123
  // Seed builders — the single definition of every PDA. Consumers on
124
124
  // @solana/web3.js should derive from these with findProgramAddressSync rather
125
125
  // than restating the seeds, which is how copies drift from the program.
126
- poolStateSeeds, commitmentTreeSeeds, tokenConfigSeeds, poolConfigSeeds, nullifierRecordSeeds, redemptionRequestSeeds, vkRegistrySeeds, depositReceiptSeeds, policyApprovalSeeds, exitDestinationSeeds, lightClientSeeds, blockHeaderSeeds, heightIndexSeeds, verifiedTransactionSeeds, derivePoolStatePDA, deriveCommitmentTreePDA, deriveNullifierRecordPDA, derivePolicyApprovalPDA, deriveExitDestinationPDA, EXIT_KIND_SOLANA_OWNER, EXIT_KIND_BTC_SCRIPT, deriveLightClientPDA, deriveBlockHeaderPDA, deriveHeightIndexPDA, deriveVkRegistryPDA, deriveRedemptionRequestPDA, deriveTokenConfigPDA, derivePoolConfigPDA, deriveDepositReceiptPDA, commitmentToBytes, } from "./pda";
126
+ poolStateSeeds, commitmentTreeSeeds, tokenConfigSeeds, poolConfigSeeds, nullifierRecordSeeds, redemptionRequestSeeds, vkRegistrySeeds, depositReceiptSeeds, policyApprovalSeeds, exitDestinationSeeds, lightClientSeeds, blockHeaderSeeds, heightIndexSeeds, verifiedTransactionSeeds, derivePoolStatePDA, deriveCommitmentTreePDA, deriveNullifierRecordPDA, deriveQueuedLeafPDA, queuedLeafSeeds, derivePolicyApprovalPDA, deriveExitDestinationPDA, EXIT_KIND_SOLANA_OWNER, EXIT_KIND_BTC_SCRIPT, deriveLightClientPDA, deriveBlockHeaderPDA, deriveHeightIndexPDA, deriveVkRegistryPDA, deriveRedemptionRequestPDA, deriveTokenConfigPDA, derivePoolConfigPDA, deriveDepositReceiptPDA, commitmentToBytes, } from "./pda";
127
127
  // ==========================================================================
128
128
  // Stealth address utilities
129
129
  // ==========================================================================
@@ -169,7 +169,7 @@ buildShieldInstructionData, buildShieldInstruction, buildApproveRedemptionSignin
169
169
  // Cancel redemption instruction
170
170
  buildCancelRedemptionInstructionData, buildCancelRedemptionInstruction, bigintTo32Bytes, bytes32ToBigint,
171
171
  // JoinSplit transact instruction
172
- buildTransactInstructionData, buildTransactInstruction,
172
+ JSFLAGS, joinSplitFlags, buildMergeQueuedLeavesInstruction, MAX_MERGE_LEAVES, buildTransactInstructionData, buildTransactInstruction,
173
173
  // JoinSplit + BTC redeem instruction
174
174
  buildRedeemInstructionData,
175
175
  // Public unshield instruction
@@ -46,6 +46,7 @@ export declare const INSTRUCTION_DISCRIMINATORS: {
46
46
  readonly APPROVE_REDEMPTION_SIGNING: 27;
47
47
  readonly SET_AUDITOR_FROZEN: 28;
48
48
  readonly SET_AUDITOR_VIEWING_PUBKEY: 29;
49
+ readonly MERGE_QUEUED_LEAVES: 40;
49
50
  readonly MAGICBLOCK_DELEGATE: 32;
50
51
  readonly MAGICBLOCK_COMMIT: 33;
51
52
  readonly MAGICBLOCK_PER_PERMISSION: 34;
@@ -280,6 +281,12 @@ export interface TransactInstructionOptions {
280
281
  accounts: {
281
282
  poolState: Address;
282
283
  commitmentTree: Address;
284
+ /**
285
+ * Supply one `QueuedLeaf` PDA per output to defer placement. Doing so also
286
+ * flips poolState and commitmentTree to read-only, which is what lets two
287
+ * spends run in the same slot.
288
+ */
289
+ queuedLeaves?: Address[];
283
290
  vkRegistry: Address;
284
291
  user: Address;
285
292
  /** Nullifier record PDAs (one per input) */
@@ -288,6 +295,50 @@ export interface TransactInstructionOptions {
288
295
  policyApproval?: Address;
289
296
  };
290
297
  }
298
+ /**
299
+ * What a JoinSplit declares it is passing, mirroring `jsflags` on-chain.
300
+ *
301
+ * This byte used to be `proof_source`, valued 0 or 1. Bit 0 keeps that exact
302
+ * meaning so no instruction-data offset moved; the rest replaced the program's
303
+ * positional heuristics, which recovered the optional account tail by counting
304
+ * backwards from the end and by asking whether an account "looked like" a
305
+ * commitment tree or carried the policy program's id.
306
+ *
307
+ * A flag only says which slot to read. The program still validates owner,
308
+ * signer and seeds on every slot, so declaring the wrong thing breaks your own
309
+ * transaction rather than buying anything.
310
+ */
311
+ export declare const JSFLAGS: {
312
+ readonly PROOF_IN_BUFFER: number;
313
+ readonly RELAYER: number;
314
+ readonly FROZEN_SOURCE_TREE: number;
315
+ readonly POLICY: number;
316
+ readonly QUEUED_LEAVES: number;
317
+ readonly RAGEQUIT: number;
318
+ };
319
+ /** Which permissioned tail a spend carries. */
320
+ export type PolicyTailKind = "none" | "verified" | "ragequit";
321
+ export interface JoinSplitTailOptions {
322
+ /** 0=inline proof (default), 1=proof in a separate ChadBuffer account */
323
+ proofSource?: 0 | 1;
324
+ /** A relayer signs and pays instead of the note owner. transact only. */
325
+ hasRelayer?: boolean;
326
+ /** A rotated-out CommitmentTree proves membership of pre-rotation notes. */
327
+ hasFrozenSourceTree?: boolean;
328
+ /**
329
+ * Permissioned tail. "verified" appends (approval, policyProgram);
330
+ * "ragequit" appends one registered ExitDestination per public output.
331
+ * Must match the pool: the program cross-checks against pool.permissioned().
332
+ */
333
+ policyTail?: PolicyTailKind;
334
+ /** Outputs are queued as QueuedLeaf PDAs instead of inserted inline. */
335
+ hasQueuedLeaves?: boolean;
336
+ }
337
+ /**
338
+ * Assemble the flags byte. Kept in one place so the data builders and the
339
+ * account builders cannot disagree about what was declared.
340
+ */
341
+ export declare function joinSplitFlags(options: JoinSplitTailOptions): number;
291
342
  /**
292
343
  * Build transact instruction data (JoinSplit)
293
344
  *
@@ -313,11 +364,9 @@ export declare function buildTransactInstructionData(options: {
313
364
  nullifiers: Uint8Array[];
314
365
  commitmentsOut: Uint8Array[];
315
366
  stealthData: Uint8Array[];
316
- /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
317
- proofSource?: 0 | 1;
318
367
  /** Reserved. Sender memos are rejected until they are proof-bound. */
319
368
  senderMemos?: Uint8Array[];
320
- }): Uint8Array;
369
+ } & JoinSplitTailOptions): Uint8Array;
321
370
  /**
322
371
  * Build a complete JoinSplit transact instruction
323
372
  *
@@ -368,9 +417,7 @@ export declare function buildRedeemInstructionData(options: {
368
417
  btcScripts: Uint8Array[];
369
418
  /** Unique request nonce(s) — single or array */
370
419
  requestNonces: bigint[];
371
- /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
372
- proofSource?: 0 | 1;
373
- }): Uint8Array;
420
+ } & JoinSplitTailOptions): Uint8Array;
374
421
  /** Unshield instruction options (multi-output) */
375
422
  export interface UnshieldInstructionOptions {
376
423
  /** Number of input notes being spent */
@@ -439,9 +486,7 @@ export declare function buildUnshieldInstructionData(options: {
439
486
  stealthData: Uint8Array[];
440
487
  /** Amount(s) being unshielded — single or array */
441
488
  unshieldAmounts: bigint[];
442
- /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
443
- proofSource?: 0 | 1;
444
- }): Uint8Array;
489
+ } & JoinSplitTailOptions): Uint8Array;
445
490
  /**
446
491
  * Build a complete unshield instruction (multi-output, disc=14)
447
492
  *
@@ -572,7 +617,8 @@ export interface MagicBlockDelegateInstructionOptions {
572
617
  };
573
618
  target: MagicBlockDelegateTarget;
574
619
  commitFrequencyMs: number;
575
- validator?: Address;
620
+ /** Required: the PER's TEE validator. An unpinned delegation is rejected on-chain. */
621
+ validator: Address;
576
622
  }
577
623
  export interface MagicBlockCommitInstructionOptions {
578
624
  accounts: {
@@ -613,7 +659,7 @@ export interface MagicBlockPerPermissionInstructionOptions {
613
659
  export declare function buildMagicBlockDelegateInstructionData(options: {
614
660
  target: MagicBlockDelegateTarget;
615
661
  commitFrequencyMs: number;
616
- validator?: Address;
662
+ validator: Address;
617
663
  }): Uint8Array;
618
664
  /**
619
665
  * Build a complete magicblock_delegate instruction.
@@ -1097,3 +1143,35 @@ export interface RotateAuditorOptions {
1097
1143
  }
1098
1144
  export declare function buildRotateAuditorInstructionData(auditor: Uint8Array, viewingPubkey: Uint8Array): Uint8Array;
1099
1145
  export declare function buildRotateAuditorInstruction(options: RotateAuditorOptions): Instruction;
1146
+ export interface MergeQueuedLeavesOptions {
1147
+ accounts: {
1148
+ /** Pays the fee. A relayer, or the holder taking the escape hatch. */
1149
+ caller: Address;
1150
+ poolState: Address;
1151
+ commitmentTree: Address;
1152
+ };
1153
+ /**
1154
+ * The leaves to place, in the order they should land: leaf `i` gets
1155
+ * `first_leaf_index + i`. Each needs the payer recorded in the account —
1156
+ * rent goes there, never to the caller, so racing to merge earns nothing.
1157
+ */
1158
+ leaves: {
1159
+ queuedLeaf: Address;
1160
+ rentRecipient: Address;
1161
+ }[];
1162
+ }
1163
+ /** Matches MAX_MERGE_LEAVES on-chain: two accounts per leaf under the 64-account cap. */
1164
+ export declare const MAX_MERGE_LEAVES = 24;
1165
+ /**
1166
+ * Place queued commitments into the tree and close their accounts.
1167
+ *
1168
+ * Permissionless: anyone may call this, which is what makes a QueuedLeaf
1169
+ * recoverable without a timeout — no operator can strand a note, because the
1170
+ * holder can merge it themselves.
1171
+ *
1172
+ * Prefer having a relayer call it. Self-merging links a Solana identity to one
1173
+ * specific leaf and the timing says the holder is in a hurry to spend it, so it
1174
+ * is the escape hatch rather than the path. Merge cadence is a privacy
1175
+ * parameter, not only a UX one.
1176
+ */
1177
+ export declare function buildMergeQueuedLeavesInstruction(options: MergeQueuedLeavesOptions): Instruction;
@@ -58,6 +58,7 @@ const INSTRUCTION = {
58
58
  SET_AUDITOR_FROZEN: 28,
59
59
  SET_AUDITOR_VIEWING_PUBKEY: 29,
60
60
  // MagicBlock ER/PER lifecycle helpers (32-33)
61
+ MERGE_QUEUED_LEAVES: 40,
61
62
  MAGICBLOCK_DELEGATE: 32,
62
63
  MAGICBLOCK_COMMIT: 33,
63
64
  MAGICBLOCK_PER_PERMISSION: 34,
@@ -381,6 +382,47 @@ export function buildCancelRedemptionInstruction(options) {
381
382
  data,
382
383
  };
383
384
  }
385
+ /**
386
+ * What a JoinSplit declares it is passing, mirroring `jsflags` on-chain.
387
+ *
388
+ * This byte used to be `proof_source`, valued 0 or 1. Bit 0 keeps that exact
389
+ * meaning so no instruction-data offset moved; the rest replaced the program's
390
+ * positional heuristics, which recovered the optional account tail by counting
391
+ * backwards from the end and by asking whether an account "looked like" a
392
+ * commitment tree or carried the policy program's id.
393
+ *
394
+ * A flag only says which slot to read. The program still validates owner,
395
+ * signer and seeds on every slot, so declaring the wrong thing breaks your own
396
+ * transaction rather than buying anything.
397
+ */
398
+ export const JSFLAGS = {
399
+ PROOF_IN_BUFFER: 1 << 0,
400
+ RELAYER: 1 << 1,
401
+ FROZEN_SOURCE_TREE: 1 << 2,
402
+ POLICY: 1 << 3,
403
+ QUEUED_LEAVES: 1 << 4,
404
+ RAGEQUIT: 1 << 5,
405
+ };
406
+ /**
407
+ * Assemble the flags byte. Kept in one place so the data builders and the
408
+ * account builders cannot disagree about what was declared.
409
+ */
410
+ export function joinSplitFlags(options) {
411
+ let flags = 0;
412
+ if ((options.proofSource ?? 0) === 1)
413
+ flags |= JSFLAGS.PROOF_IN_BUFFER;
414
+ if (options.hasRelayer)
415
+ flags |= JSFLAGS.RELAYER;
416
+ if (options.hasFrozenSourceTree)
417
+ flags |= JSFLAGS.FROZEN_SOURCE_TREE;
418
+ if (options.policyTail === "verified")
419
+ flags |= JSFLAGS.POLICY;
420
+ if (options.policyTail === "ragequit")
421
+ flags |= JSFLAGS.RAGEQUIT;
422
+ if (options.hasQueuedLeaves)
423
+ flags |= JSFLAGS.QUEUED_LEAVES;
424
+ return flags;
425
+ }
384
426
  /**
385
427
  * Build transact instruction data (JoinSplit)
386
428
  *
@@ -425,7 +467,7 @@ export function buildTransactInstructionData(options) {
425
467
  data[offset++] = nInputs;
426
468
  data[offset++] = nOutputs;
427
469
  data[offset++] = 0; // n_public_outputs = 0 for transact
428
- data[offset++] = proofSource;
470
+ data[offset++] = joinSplitFlags(options);
429
471
  // Proof (256 bytes, only in inline mode)
430
472
  if (proofSource === 0 && proofBytes) {
431
473
  data.set(proofBytes, offset);
@@ -467,6 +509,11 @@ export function buildTransactInstructionData(options) {
467
509
  */
468
510
  export function buildTransactInstruction(options) {
469
511
  const config = getConfig();
512
+ const queuedLeaves = options.accounts.queuedLeaves ?? [];
513
+ const queued = queuedLeaves.length > 0;
514
+ if (queued && queuedLeaves.length !== options.nOutputs) {
515
+ throw new Error(`Queued placement needs one QueuedLeaf PDA per output: expected ${options.nOutputs}, got ${queuedLeaves.length}`);
516
+ }
470
517
  const data = buildTransactInstructionData({
471
518
  nInputs: options.nInputs,
472
519
  nOutputs: options.nOutputs,
@@ -477,10 +524,21 @@ export function buildTransactInstruction(options) {
477
524
  commitmentsOut: options.commitmentsOut,
478
525
  stealthData: options.stealthData,
479
526
  senderMemos: options.senderMemos,
527
+ // The account list below appends the policy pair exactly when the caller
528
+ // supplies an approval, so the declared tail is derived from the same fact
529
+ // rather than asked for twice.
530
+ policyTail: options.accounts.policyApproval ? "verified" : "none",
531
+ hasQueuedLeaves: queued,
480
532
  });
533
+ // A queued spend writes neither shared account — the tree is read for
534
+ // is_valid_root, pool_state for its policy flags — and the program REJECTS
535
+ // them as writable. Sealevel serialises on the declared metas, not on what
536
+ // the program does, so leaving them writable would take both locks and
537
+ // silently cost the parallelism the queue exists for.
538
+ const sharedRole = queued ? AccountRole.READONLY : AccountRole.WRITABLE;
481
539
  const accounts = [
482
- { address: options.accounts.poolState, role: AccountRole.WRITABLE },
483
- { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
540
+ { address: options.accounts.poolState, role: sharedRole },
541
+ { address: options.accounts.commitmentTree, role: sharedRole },
484
542
  { address: options.accounts.vkRegistry, role: AccountRole.READONLY },
485
543
  { address: options.accounts.user, role: AccountRole.WRITABLE_SIGNER },
486
544
  { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
@@ -496,6 +554,11 @@ export function buildTransactInstruction(options) {
496
554
  role: AccountRole.READONLY,
497
555
  });
498
556
  }
557
+ // Order matches resolve_joinsplit_tail: relayer, source tree, policy, queued
558
+ // leaves, proof buffer. One PDA per output, in output order.
559
+ for (const leaf of queuedLeaves) {
560
+ accounts.push({ address: leaf, role: AccountRole.WRITABLE });
561
+ }
499
562
  return {
500
563
  programAddress: config.utxopiaProgramId,
501
564
  accounts,
@@ -578,7 +641,7 @@ export function buildRedeemInstructionData(options) {
578
641
  data[offset++] = nInputs;
579
642
  data[offset++] = nOutputs;
580
643
  data[offset++] = nPublicOutputs;
581
- data[offset++] = proofSource;
644
+ data[offset++] = joinSplitFlags(options);
582
645
  // Proof (256 bytes, only in inline mode)
583
646
  if (proofSource === 0 && proofBytes) {
584
647
  data.set(proofBytes, offset);
@@ -669,7 +732,7 @@ export function buildUnshieldInstructionData(options) {
669
732
  data[offset++] = nInputs;
670
733
  data[offset++] = nOutputs;
671
734
  data[offset++] = nPublicOutputs;
672
- data[offset++] = proofSource;
735
+ data[offset++] = joinSplitFlags(options);
673
736
  // Proof (256 bytes, only in inline mode)
674
737
  if (proofSource === 0 && proofBytes) {
675
738
  data.set(proofBytes, offset);
@@ -898,14 +961,12 @@ export function buildMagicBlockDelegateInstructionData(options) {
898
961
  if (options.commitFrequencyMs > 0xffffffff) {
899
962
  throw new Error("commitFrequencyMs must fit in u32");
900
963
  }
901
- const data = new Uint8Array(options.validator ? 38 : 6);
964
+ const data = new Uint8Array(38);
902
965
  const view = new DataView(data.buffer);
903
966
  data[0] = INSTRUCTION.MAGICBLOCK_DELEGATE;
904
967
  data[1] = magicBlockDelegateTargetByte(options.target);
905
968
  view.setUint32(2, options.commitFrequencyMs, true);
906
- if (options.validator) {
907
- data.set(addressToBytes(options.validator), 6);
908
- }
969
+ data.set(addressToBytes(options.validator), 6);
909
970
  return data;
910
971
  }
911
972
  /**
@@ -1732,3 +1793,50 @@ export function buildRotateAuditorInstruction(options) {
1732
1793
  data: buildRotateAuditorInstructionData(options.auditor, options.viewingPubkey),
1733
1794
  };
1734
1795
  }
1796
+ /** Matches MAX_MERGE_LEAVES on-chain: two accounts per leaf under the 64-account cap. */
1797
+ export const MAX_MERGE_LEAVES = 24;
1798
+ /**
1799
+ * Place queued commitments into the tree and close their accounts.
1800
+ *
1801
+ * Permissionless: anyone may call this, which is what makes a QueuedLeaf
1802
+ * recoverable without a timeout — no operator can strand a note, because the
1803
+ * holder can merge it themselves.
1804
+ *
1805
+ * Prefer having a relayer call it. Self-merging links a Solana identity to one
1806
+ * specific leaf and the timing says the holder is in a hurry to spend it, so it
1807
+ * is the escape hatch rather than the path. Merge cadence is a privacy
1808
+ * parameter, not only a UX one.
1809
+ */
1810
+ export function buildMergeQueuedLeavesInstruction(options) {
1811
+ const config = getConfig();
1812
+ if (options.leaves.length === 0) {
1813
+ throw new Error("merge_queued_leaves needs at least one leaf");
1814
+ }
1815
+ if (options.leaves.length > MAX_MERGE_LEAVES) {
1816
+ throw new Error(`merge_queued_leaves takes at most ${MAX_MERGE_LEAVES} leaves, got ${options.leaves.length}`);
1817
+ }
1818
+ // Duplicates would insert one commitment at two leaf indices, and nullifiers
1819
+ // are derived from (key, leaf index) — the program refuses it, but catching it
1820
+ // here names the problem instead of surfacing NullifierAlreadyUsed.
1821
+ const seen = new Set();
1822
+ for (const { queuedLeaf } of options.leaves) {
1823
+ if (seen.has(queuedLeaf)) {
1824
+ throw new Error(`Duplicate queued leaf in one merge: ${queuedLeaf}`);
1825
+ }
1826
+ seen.add(queuedLeaf);
1827
+ }
1828
+ const accounts = [
1829
+ { address: options.accounts.caller, role: AccountRole.WRITABLE_SIGNER },
1830
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
1831
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
1832
+ ];
1833
+ for (const { queuedLeaf, rentRecipient } of options.leaves) {
1834
+ accounts.push({ address: queuedLeaf, role: AccountRole.WRITABLE });
1835
+ accounts.push({ address: rentRecipient, role: AccountRole.WRITABLE });
1836
+ }
1837
+ return {
1838
+ programAddress: config.utxopiaProgramId,
1839
+ accounts,
1840
+ data: Uint8Array.of(INSTRUCTION.MERGE_QUEUED_LEAVES),
1841
+ };
1842
+ }
package/dist/pda.d.ts CHANGED
@@ -21,6 +21,7 @@ export declare const PDA_SEEDS: {
21
21
  readonly VK_REGISTRY: "vk_registry";
22
22
  readonly TOKEN_CONFIG: "token_config";
23
23
  readonly POOL_CONFIG: "pool_config";
24
+ readonly QUEUED_LEAF: "queued_leaf";
24
25
  };
25
26
  /** `["pool_state", pool_id]` — pool_id is the pool's zkBTC mint. */
26
27
  export declare function poolStateSeeds(poolId: Address | Uint8Array): Uint8Array[];
@@ -92,6 +93,18 @@ export declare function derivePoolConfigPDA(poolState: Address | Uint8Array, pro
92
93
  * — re-deriving them would make every already-spent note spendable again.
93
94
  */
94
95
  export declare function deriveNullifierRecordPDA(nullifierHash: Uint8Array, poolState: Address | Uint8Array, treeIndex?: number, programId?: Address): Promise<[Address, number]>;
96
+ /**
97
+ * Seeds for a `QueuedLeaf`: `["queued_leaf", pool_state, commitment]`.
98
+ *
99
+ * The commitment is the seed because it is already unique per output — which
100
+ * makes a second attempt to queue the same commitment fail on account creation
101
+ * rather than silently produce a second leaf for one note.
102
+ */
103
+ export declare function queuedLeafSeeds(poolState: Address | Uint8Array, commitment: Uint8Array): Uint8Array[];
104
+ /**
105
+ * Derive the `QueuedLeaf` PDA a queued spend creates for one output.
106
+ */
107
+ export declare function deriveQueuedLeafPDA(poolState: Address | Uint8Array, commitment: Uint8Array, programId?: Address): Promise<[Address, number]>;
95
108
  /**
96
109
  * Derive one-time PolicyApproval PDA.
97
110
  * Seeds: ["policy_approval", pool_state, request_hash, nonce]
package/dist/pda.js CHANGED
@@ -33,6 +33,7 @@ export const PDA_SEEDS = {
33
33
  VK_REGISTRY: "vk_registry",
34
34
  TOKEN_CONFIG: "token_config",
35
35
  POOL_CONFIG: "pool_config",
36
+ QUEUED_LEAF: "queued_leaf",
36
37
  };
37
38
  // =============================================================================
38
39
  // Seed builders — the single definition of every program-derived address
@@ -240,6 +241,30 @@ export async function deriveNullifierRecordPDA(nullifierHash, poolState, treeInd
240
241
  });
241
242
  return [result[0], result[1]];
242
243
  }
244
+ /**
245
+ * Seeds for a `QueuedLeaf`: `["queued_leaf", pool_state, commitment]`.
246
+ *
247
+ * The commitment is the seed because it is already unique per output — which
248
+ * makes a second attempt to queue the same commitment fail on account creation
249
+ * rather than silently produce a second leaf for one note.
250
+ */
251
+ export function queuedLeafSeeds(poolState, commitment) {
252
+ return [
253
+ enc(PDA_SEEDS.QUEUED_LEAF),
254
+ seedBytes(poolState, "poolState"),
255
+ seedBytes(commitment, "commitment"),
256
+ ];
257
+ }
258
+ /**
259
+ * Derive the `QueuedLeaf` PDA a queued spend creates for one output.
260
+ */
261
+ export async function deriveQueuedLeafPDA(poolState, commitment, programId = UTXOPIA_PROGRAM_ID) {
262
+ const result = await getProgramDerivedAddress({
263
+ programAddress: programId,
264
+ seeds: queuedLeafSeeds(poolState, commitment),
265
+ });
266
+ return [result[0], result[1]];
267
+ }
243
268
  /**
244
269
  * Derive one-time PolicyApproval PDA.
245
270
  * Seeds: ["policy_approval", pool_state, request_hash, nonce]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utxopia/sdk",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.8",
4
4
  "description": "UTXOpia SDK - Private Bitcoin across Solana and Sui",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -230,7 +230,16 @@ export class CommitmentTreeIndex {
230
230
  throw new Error("Tree is full");
231
231
  }
232
232
 
233
- // Store in map for lookup
233
+ // Store in map for lookup.
234
+ //
235
+ // Test-only helper, and this map is why: a commitment is NOT unique in the
236
+ // tree. Paying the same BTC deposit address twice for the same amount yields
237
+ // byte-identical commitments (complete_deposit hashes note_public_key,
238
+ // token_id and shielded_amount), so a second add overwrites the first and
239
+ // getMerkleProof then returns only the later leaf. Production never takes
240
+ // this path — announcements dedupe on leafIndex and proofs come from
241
+ // /api/tree/proof/:leaf_index — but do not promote this class to a wallet
242
+ // path without keying on leafIndex instead.
234
243
  const commitmentHex = commitment.toString(16).padStart(64, "0");
235
244
  this.commitments.set(commitmentHex, { index: leafIndex, amount });
236
245
 
package/src/index.ts CHANGED
@@ -504,6 +504,8 @@ export {
504
504
  derivePoolStatePDA,
505
505
  deriveCommitmentTreePDA,
506
506
  deriveNullifierRecordPDA,
507
+ deriveQueuedLeafPDA,
508
+ queuedLeafSeeds,
507
509
  derivePolicyApprovalPDA,
508
510
  deriveExitDestinationPDA,
509
511
  EXIT_KIND_SOLANA_OWNER,
@@ -717,6 +719,10 @@ export {
717
719
  bigintTo32Bytes,
718
720
  bytes32ToBigint,
719
721
  // JoinSplit transact instruction
722
+ JSFLAGS,
723
+ joinSplitFlags,
724
+ buildMergeQueuedLeavesInstruction,
725
+ MAX_MERGE_LEAVES,
720
726
  buildTransactInstructionData,
721
727
  buildTransactInstruction,
722
728
  // JoinSplit + BTC redeem instruction
@@ -758,6 +764,8 @@ export {
758
764
  buildRotateAuditorInstructionData,
759
765
  buildRotateAuditorInstruction,
760
766
  type MagicBlockDelegateTarget,
767
+ type JoinSplitTailOptions,
768
+ type PolicyTailKind,
761
769
  type MagicBlockDelegateInstructionOptions,
762
770
  type MagicBlockCommitInstructionOptions,
763
771
  type MagicBlockPerPermissionOperation,
@@ -85,6 +85,7 @@ const INSTRUCTION = {
85
85
  SET_AUDITOR_FROZEN: 28,
86
86
  SET_AUDITOR_VIEWING_PUBKEY: 29,
87
87
  // MagicBlock ER/PER lifecycle helpers (32-33)
88
+ MERGE_QUEUED_LEAVES: 40,
88
89
  MAGICBLOCK_DELEGATE: 32,
89
90
  MAGICBLOCK_COMMIT: 33,
90
91
  MAGICBLOCK_PER_PERMISSION: 34,
@@ -619,6 +620,12 @@ export interface TransactInstructionOptions {
619
620
  accounts: {
620
621
  poolState: Address;
621
622
  commitmentTree: Address;
623
+ /**
624
+ * Supply one `QueuedLeaf` PDA per output to defer placement. Doing so also
625
+ * flips poolState and commitmentTree to read-only, which is what lets two
626
+ * spends run in the same slot.
627
+ */
628
+ queuedLeaves?: Address[];
622
629
  vkRegistry: Address;
623
630
  user: Address;
624
631
  /** Nullifier record PDAs (one per input) */
@@ -628,6 +635,63 @@ export interface TransactInstructionOptions {
628
635
  };
629
636
  }
630
637
 
638
+ /**
639
+ * What a JoinSplit declares it is passing, mirroring `jsflags` on-chain.
640
+ *
641
+ * This byte used to be `proof_source`, valued 0 or 1. Bit 0 keeps that exact
642
+ * meaning so no instruction-data offset moved; the rest replaced the program's
643
+ * positional heuristics, which recovered the optional account tail by counting
644
+ * backwards from the end and by asking whether an account "looked like" a
645
+ * commitment tree or carried the policy program's id.
646
+ *
647
+ * A flag only says which slot to read. The program still validates owner,
648
+ * signer and seeds on every slot, so declaring the wrong thing breaks your own
649
+ * transaction rather than buying anything.
650
+ */
651
+ export const JSFLAGS = {
652
+ PROOF_IN_BUFFER: 1 << 0,
653
+ RELAYER: 1 << 1,
654
+ FROZEN_SOURCE_TREE: 1 << 2,
655
+ POLICY: 1 << 3,
656
+ QUEUED_LEAVES: 1 << 4,
657
+ RAGEQUIT: 1 << 5,
658
+ } as const;
659
+
660
+ /** Which permissioned tail a spend carries. */
661
+ export type PolicyTailKind = "none" | "verified" | "ragequit";
662
+
663
+ export interface JoinSplitTailOptions {
664
+ /** 0=inline proof (default), 1=proof in a separate ChadBuffer account */
665
+ proofSource?: 0 | 1;
666
+ /** A relayer signs and pays instead of the note owner. transact only. */
667
+ hasRelayer?: boolean;
668
+ /** A rotated-out CommitmentTree proves membership of pre-rotation notes. */
669
+ hasFrozenSourceTree?: boolean;
670
+ /**
671
+ * Permissioned tail. "verified" appends (approval, policyProgram);
672
+ * "ragequit" appends one registered ExitDestination per public output.
673
+ * Must match the pool: the program cross-checks against pool.permissioned().
674
+ */
675
+ policyTail?: PolicyTailKind;
676
+ /** Outputs are queued as QueuedLeaf PDAs instead of inserted inline. */
677
+ hasQueuedLeaves?: boolean;
678
+ }
679
+
680
+ /**
681
+ * Assemble the flags byte. Kept in one place so the data builders and the
682
+ * account builders cannot disagree about what was declared.
683
+ */
684
+ export function joinSplitFlags(options: JoinSplitTailOptions): number {
685
+ let flags = 0;
686
+ if ((options.proofSource ?? 0) === 1) flags |= JSFLAGS.PROOF_IN_BUFFER;
687
+ if (options.hasRelayer) flags |= JSFLAGS.RELAYER;
688
+ if (options.hasFrozenSourceTree) flags |= JSFLAGS.FROZEN_SOURCE_TREE;
689
+ if (options.policyTail === "verified") flags |= JSFLAGS.POLICY;
690
+ if (options.policyTail === "ragequit") flags |= JSFLAGS.RAGEQUIT;
691
+ if (options.hasQueuedLeaves) flags |= JSFLAGS.QUEUED_LEAVES;
692
+ return flags;
693
+ }
694
+
631
695
  /**
632
696
  * Build transact instruction data (JoinSplit)
633
697
  *
@@ -653,11 +717,9 @@ export function buildTransactInstructionData(options: {
653
717
  nullifiers: Uint8Array[];
654
718
  commitmentsOut: Uint8Array[];
655
719
  stealthData: Uint8Array[];
656
- /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
657
- proofSource?: 0 | 1;
658
720
  /** Reserved. Sender memos are rejected until they are proof-bound. */
659
721
  senderMemos?: Uint8Array[];
660
- }): Uint8Array {
722
+ } & JoinSplitTailOptions): Uint8Array {
661
723
  const { nInputs, nOutputs, proofBytes, merkleRoot, boundParamsHash, nullifiers, commitmentsOut, stealthData, senderMemos } = options;
662
724
  const proofSource = options.proofSource ?? 0;
663
725
 
@@ -693,7 +755,7 @@ export function buildTransactInstructionData(options: {
693
755
  data[offset++] = nInputs;
694
756
  data[offset++] = nOutputs;
695
757
  data[offset++] = 0; // n_public_outputs = 0 for transact
696
- data[offset++] = proofSource;
758
+ data[offset++] = joinSplitFlags(options);
697
759
 
698
760
  // Proof (256 bytes, only in inline mode)
699
761
  if (proofSource === 0 && proofBytes) {
@@ -743,6 +805,13 @@ export function buildTransactInstructionData(options: {
743
805
  */
744
806
  export function buildTransactInstruction(options: TransactInstructionOptions): Instruction {
745
807
  const config = getConfig();
808
+ const queuedLeaves = options.accounts.queuedLeaves ?? [];
809
+ const queued = queuedLeaves.length > 0;
810
+ if (queued && queuedLeaves.length !== options.nOutputs) {
811
+ throw new Error(
812
+ `Queued placement needs one QueuedLeaf PDA per output: expected ${options.nOutputs}, got ${queuedLeaves.length}`
813
+ );
814
+ }
746
815
 
747
816
  const data = buildTransactInstructionData({
748
817
  nInputs: options.nInputs,
@@ -754,11 +823,23 @@ export function buildTransactInstruction(options: TransactInstructionOptions): I
754
823
  commitmentsOut: options.commitmentsOut,
755
824
  stealthData: options.stealthData,
756
825
  senderMemos: options.senderMemos,
826
+ // The account list below appends the policy pair exactly when the caller
827
+ // supplies an approval, so the declared tail is derived from the same fact
828
+ // rather than asked for twice.
829
+ policyTail: options.accounts.policyApproval ? "verified" : "none",
830
+ hasQueuedLeaves: queued,
757
831
  });
758
832
 
833
+ // A queued spend writes neither shared account — the tree is read for
834
+ // is_valid_root, pool_state for its policy flags — and the program REJECTS
835
+ // them as writable. Sealevel serialises on the declared metas, not on what
836
+ // the program does, so leaving them writable would take both locks and
837
+ // silently cost the parallelism the queue exists for.
838
+ const sharedRole = queued ? AccountRole.READONLY : AccountRole.WRITABLE;
839
+
759
840
  const accounts: Instruction["accounts"] = [
760
- { address: options.accounts.poolState, role: AccountRole.WRITABLE },
761
- { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
841
+ { address: options.accounts.poolState, role: sharedRole },
842
+ { address: options.accounts.commitmentTree, role: sharedRole },
762
843
  { address: options.accounts.vkRegistry, role: AccountRole.READONLY },
763
844
  { address: options.accounts.user, role: AccountRole.WRITABLE_SIGNER },
764
845
  { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
@@ -775,6 +856,11 @@ export function buildTransactInstruction(options: TransactInstructionOptions): I
775
856
  role: AccountRole.READONLY,
776
857
  });
777
858
  }
859
+ // Order matches resolve_joinsplit_tail: relayer, source tree, policy, queued
860
+ // leaves, proof buffer. One PDA per output, in output order.
861
+ for (const leaf of queuedLeaves) {
862
+ accounts.push({ address: leaf, role: AccountRole.WRITABLE });
863
+ }
778
864
 
779
865
  return {
780
866
  programAddress: config.utxopiaProgramId,
@@ -826,8 +912,7 @@ export function buildRedeemInstructionData(options: {
826
912
  /** Unique request nonce(s) — single or array */
827
913
  requestNonces: bigint[];
828
914
  /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
829
- proofSource?: 0 | 1;
830
- }): Uint8Array {
915
+ } & JoinSplitTailOptions): Uint8Array {
831
916
  const {
832
917
  nInputs, nOutputs, proofBytes, merkleRoot, boundParamsHash,
833
918
  nullifiers, commitmentsOut, stealthData, redeemAmounts, btcScripts, requestNonces,
@@ -888,7 +973,7 @@ export function buildRedeemInstructionData(options: {
888
973
  data[offset++] = nInputs;
889
974
  data[offset++] = nOutputs;
890
975
  data[offset++] = nPublicOutputs;
891
- data[offset++] = proofSource;
976
+ data[offset++] = joinSplitFlags(options);
892
977
 
893
978
  // Proof (256 bytes, only in inline mode)
894
979
  if (proofSource === 0 && proofBytes) {
@@ -1010,8 +1095,7 @@ export function buildUnshieldInstructionData(options: {
1010
1095
  /** Amount(s) being unshielded — single or array */
1011
1096
  unshieldAmounts: bigint[];
1012
1097
  /** 0=inline proof (default), 1=proof in separate ChadBuffer account */
1013
- proofSource?: 0 | 1;
1014
- }): Uint8Array {
1098
+ } & JoinSplitTailOptions): Uint8Array {
1015
1099
  const { nInputs, nOutputs, proofBytes, merkleRoot, boundParamsHash, nullifiers, commitmentsOut, stealthData, unshieldAmounts } = options;
1016
1100
  const nPublicOutputs = options.nPublicOutputs ?? unshieldAmounts.length;
1017
1101
  const proofSource = options.proofSource ?? 0;
@@ -1054,7 +1138,7 @@ export function buildUnshieldInstructionData(options: {
1054
1138
  data[offset++] = nInputs;
1055
1139
  data[offset++] = nOutputs;
1056
1140
  data[offset++] = nPublicOutputs;
1057
- data[offset++] = proofSource;
1141
+ data[offset++] = joinSplitFlags(options);
1058
1142
 
1059
1143
  // Proof (256 bytes, only in inline mode)
1060
1144
  if (proofSource === 0 && proofBytes) {
@@ -1380,7 +1464,8 @@ export interface MagicBlockDelegateInstructionOptions {
1380
1464
  };
1381
1465
  target: MagicBlockDelegateTarget;
1382
1466
  commitFrequencyMs: number;
1383
- validator?: Address;
1467
+ /** Required: the PER's TEE validator. An unpinned delegation is rejected on-chain. */
1468
+ validator: Address;
1384
1469
  }
1385
1470
 
1386
1471
  export interface MagicBlockCommitInstructionOptions {
@@ -1431,7 +1516,7 @@ function magicBlockDelegateTargetByte(target: MagicBlockDelegateTarget): number
1431
1516
  export function buildMagicBlockDelegateInstructionData(options: {
1432
1517
  target: MagicBlockDelegateTarget;
1433
1518
  commitFrequencyMs: number;
1434
- validator?: Address;
1519
+ validator: Address;
1435
1520
  }): Uint8Array {
1436
1521
  if (!Number.isInteger(options.commitFrequencyMs) || options.commitFrequencyMs < 0) {
1437
1522
  throw new Error("commitFrequencyMs must be a non-negative u32");
@@ -1440,14 +1525,12 @@ export function buildMagicBlockDelegateInstructionData(options: {
1440
1525
  throw new Error("commitFrequencyMs must fit in u32");
1441
1526
  }
1442
1527
 
1443
- const data = new Uint8Array(options.validator ? 38 : 6);
1528
+ const data = new Uint8Array(38);
1444
1529
  const view = new DataView(data.buffer);
1445
1530
  data[0] = INSTRUCTION.MAGICBLOCK_DELEGATE;
1446
1531
  data[1] = magicBlockDelegateTargetByte(options.target);
1447
1532
  view.setUint32(2, options.commitFrequencyMs, true);
1448
- if (options.validator) {
1449
- data.set(addressToBytes(options.validator), 6);
1450
- }
1533
+ data.set(addressToBytes(options.validator), 6);
1451
1534
  return data;
1452
1535
  }
1453
1536
 
@@ -2604,3 +2687,77 @@ export function buildRotateAuditorInstruction(options: RotateAuditorOptions): In
2604
2687
  data: buildRotateAuditorInstructionData(options.auditor, options.viewingPubkey),
2605
2688
  };
2606
2689
  }
2690
+
2691
+ // =============================================================================
2692
+ // merge_queued_leaves (disc=40)
2693
+ // =============================================================================
2694
+
2695
+ export interface MergeQueuedLeavesOptions {
2696
+ accounts: {
2697
+ /** Pays the fee. A relayer, or the holder taking the escape hatch. */
2698
+ caller: Address;
2699
+ poolState: Address;
2700
+ commitmentTree: Address;
2701
+ };
2702
+ /**
2703
+ * The leaves to place, in the order they should land: leaf `i` gets
2704
+ * `first_leaf_index + i`. Each needs the payer recorded in the account —
2705
+ * rent goes there, never to the caller, so racing to merge earns nothing.
2706
+ */
2707
+ leaves: { queuedLeaf: Address; rentRecipient: Address }[];
2708
+ }
2709
+
2710
+ /** Matches MAX_MERGE_LEAVES on-chain: two accounts per leaf under the 64-account cap. */
2711
+ export const MAX_MERGE_LEAVES = 24;
2712
+
2713
+ /**
2714
+ * Place queued commitments into the tree and close their accounts.
2715
+ *
2716
+ * Permissionless: anyone may call this, which is what makes a QueuedLeaf
2717
+ * recoverable without a timeout — no operator can strand a note, because the
2718
+ * holder can merge it themselves.
2719
+ *
2720
+ * Prefer having a relayer call it. Self-merging links a Solana identity to one
2721
+ * specific leaf and the timing says the holder is in a hurry to spend it, so it
2722
+ * is the escape hatch rather than the path. Merge cadence is a privacy
2723
+ * parameter, not only a UX one.
2724
+ */
2725
+ export function buildMergeQueuedLeavesInstruction(
2726
+ options: MergeQueuedLeavesOptions
2727
+ ): Instruction {
2728
+ const config = getConfig();
2729
+ if (options.leaves.length === 0) {
2730
+ throw new Error("merge_queued_leaves needs at least one leaf");
2731
+ }
2732
+ if (options.leaves.length > MAX_MERGE_LEAVES) {
2733
+ throw new Error(
2734
+ `merge_queued_leaves takes at most ${MAX_MERGE_LEAVES} leaves, got ${options.leaves.length}`
2735
+ );
2736
+ }
2737
+ // Duplicates would insert one commitment at two leaf indices, and nullifiers
2738
+ // are derived from (key, leaf index) — the program refuses it, but catching it
2739
+ // here names the problem instead of surfacing NullifierAlreadyUsed.
2740
+ const seen = new Set<string>();
2741
+ for (const { queuedLeaf } of options.leaves) {
2742
+ if (seen.has(queuedLeaf)) {
2743
+ throw new Error(`Duplicate queued leaf in one merge: ${queuedLeaf}`);
2744
+ }
2745
+ seen.add(queuedLeaf);
2746
+ }
2747
+
2748
+ const accounts: Instruction["accounts"] = [
2749
+ { address: options.accounts.caller, role: AccountRole.WRITABLE_SIGNER },
2750
+ { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2751
+ { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
2752
+ ];
2753
+ for (const { queuedLeaf, rentRecipient } of options.leaves) {
2754
+ accounts.push({ address: queuedLeaf, role: AccountRole.WRITABLE });
2755
+ accounts.push({ address: rentRecipient, role: AccountRole.WRITABLE });
2756
+ }
2757
+
2758
+ return {
2759
+ programAddress: config.utxopiaProgramId,
2760
+ accounts,
2761
+ data: Uint8Array.of(INSTRUCTION.MERGE_QUEUED_LEAVES),
2762
+ };
2763
+ }
package/src/pda.ts CHANGED
@@ -46,6 +46,7 @@ export const PDA_SEEDS = {
46
46
  VK_REGISTRY: "vk_registry",
47
47
  TOKEN_CONFIG: "token_config",
48
48
  POOL_CONFIG: "pool_config",
49
+ QUEUED_LEAF: "queued_leaf",
49
50
  } as const;
50
51
 
51
52
  // =============================================================================
@@ -322,6 +323,39 @@ export async function deriveNullifierRecordPDA(
322
323
  return [result[0], result[1]];
323
324
  }
324
325
 
326
+ /**
327
+ * Seeds for a `QueuedLeaf`: `["queued_leaf", pool_state, commitment]`.
328
+ *
329
+ * The commitment is the seed because it is already unique per output — which
330
+ * makes a second attempt to queue the same commitment fail on account creation
331
+ * rather than silently produce a second leaf for one note.
332
+ */
333
+ export function queuedLeafSeeds(
334
+ poolState: Address | Uint8Array,
335
+ commitment: Uint8Array
336
+ ): Uint8Array[] {
337
+ return [
338
+ enc(PDA_SEEDS.QUEUED_LEAF),
339
+ seedBytes(poolState, "poolState"),
340
+ seedBytes(commitment, "commitment"),
341
+ ];
342
+ }
343
+
344
+ /**
345
+ * Derive the `QueuedLeaf` PDA a queued spend creates for one output.
346
+ */
347
+ export async function deriveQueuedLeafPDA(
348
+ poolState: Address | Uint8Array,
349
+ commitment: Uint8Array,
350
+ programId: Address = UTXOPIA_PROGRAM_ID
351
+ ): Promise<[Address, number]> {
352
+ const result = await getProgramDerivedAddress({
353
+ programAddress: programId,
354
+ seeds: queuedLeafSeeds(poolState, commitment),
355
+ });
356
+ return [result[0], result[1]];
357
+ }
358
+
325
359
  /**
326
360
  * Derive one-time PolicyApproval PDA.
327
361
  * Seeds: ["policy_approval", pool_state, request_hash, nonce]