Skip to main content

Overview

V18 uses per-operation hooks objects (not a single generic TransactionCallbacks). Each factory’s deps.hooks or options.hooks slot accepts a named interface specific to that operation, with named per-phase / per-step hook keys. Hooks observe the operation as it moves through validation, fetching, ZK proving, transaction submission, MPC callback waiting, and completion. The two transaction-level building blocks every hooks interface uses:
// Direct (non-MPC) transaction step — used for the synchronous registration sub-steps
// and for any single-tx operation.
interface DirectTransactionStepHooks {
  readonly interceptInstructions?: (event: InstructionsReadyEvent) => Promise<readonly Instruction[]> | readonly Instruction[];
  readonly onTransactionBuilt?:    (event: TransactionBuiltEvent)  => Promise<void>;
  readonly onTransactionSigned?:   (event: TransactionSignedEvent) => Promise<void>;
  readonly onPreSend?:             (event: PreSendEvent)           => Promise<void>;
  readonly onPostSend?:            (event: PostSendEvent)          => Promise<void>;
}

// Same, but with an extra `onSkipped` slot fired when the step is a no-op
// because the on-chain state already satisfies it (e.g. initUserAccount when
// the account already exists).
interface SkippableDirectTransactionStepHooks extends DirectTransactionStepHooks {
  readonly onSkipped?: (event: { readonly reason: string }) => Promise<void>;
}

// MPC transaction step — extends DirectTransactionStepHooks with Arcium MPC
// progress events and rent-reclaim outcome hooks.
interface MpcTransactionStepHooks extends DirectTransactionStepHooks {
  readonly onMonitorStarted?:       (event: { readonly computationAccount: Address }) => Promise<void>;
  readonly onProgress?:             (event: ComputationMonitorProgressEvent)          => Promise<void>;
  readonly onFinalized?:            (event: ComputationFinalizedEvent)                => Promise<void>;
  readonly onRentReclaimSubmitted?: (event: { readonly signature: TransactionSignature }) => Promise<void>;
  readonly onRentReclaimError?:     (event: { readonly error: string })               => Promise<void>;
}
Skipped steps fire onSkipped, not onPreSend / onPostSend. This is the V18 way to observe “the step was a no-op because the on-chain state was already correct.”

Usage — Registration

V18 registration uses a single hooks option (RegistrationHooks) with named per-step slots and top-level phase hooks. Each skippable step slot takes { onPreSend, onPostSend, onSkipped }.
import { getUserRegistrationFunction } from "@umbra-privacy/sdk/registration";

const register = getUserRegistrationFunction({ client });

await register({
  confidential: true,
  anonymous: true,
  hooks: {
    onValidationStart:       async () => setStatus("Validating..."),
    onAccountFetchComplete:  async ({ userAccountExists }) => setStatus(userAccountExists ? "Account exists" : "Will create account"),
    initUserAccount: {
      onSkipped:  async ({ reason }) => setStatus("Account already initialised: " + reason),
      onPreSend:  async () => setStatus("Creating account..."),
      onPostSend: async ({ signature }) => setProgress(33),
    },
    registerX25519PublicKey: {
      onSkipped:  async ({ reason }) => setStatus("X25519 key already registered: " + reason),
      onPreSend:  async () => setStatus("Registering encryption key..."),
      onPostSend: async ({ signature }) => setProgress(66),
    },
    registerAnonymousUsage: {
      onSkipped:  async ({ reason }) => setStatus("Anonymous usage already enabled: " + reason),
      onPreSend:  async () => setStatus("Enabling anonymous mode..."),
      onPostSend: async ({ signature }) => { setProgress(100); setStatus("Done!"); },
    },
    onComplete: async (result) => setStatus(`Registered with ${result.signatures.length} signatures`),
    onError:    async ({ phase, error }) => console.error("Registration failed at", phase, error),
  },
});
Step slot names: initUserAccount, registerX25519PublicKey, registerAnonymousUsage. Each fires onSkipped when the on-chain state already satisfies the step, onPreSend before the transaction is submitted, and onPostSend after it confirms.

Usage — Stealth Pool Note creation

Creating a Stealth Pool Note involves up to three transactions. Each has its own callbacks slot inside the options argument:
import { getATAIntoSelfBurnableStealthPoolNoteCreatorFunction } from "@umbra-privacy/sdk/deposit";
import { getATAIntoStealthPoolNoteCreatorProver } from "@umbra-privacy/sdk/zk-prover";

const createNote = getATAIntoSelfBurnableStealthPoolNoteCreatorFunction(
  { client },
  { zkProver: getATAIntoStealthPoolNoteCreatorProver() },
);

await createNote(
  { destinationAddress: recipient, mint, amount },
  {
    createUtxo: {
      pre: async (tx) => console.log("Submitting note write..."),
      post: async (tx, sig) => console.log("Note written:", sig),
    },
    createProofAccount: {
      pre: async (tx) => console.log("Creating proof account..."),
      post: async (tx, sig) => console.log("Proof account:", sig),
    },
    closeProofAccount: {
      // Only fires if a stale proof account was found and closed.
      pre: async (tx) => console.log("Closing stale proof account..."),
      post: async (tx, sig) => console.log("Closed:", sig),
    },
  },
);

Usage — Single-transaction operations

Deposits, withdrawals, conversions, and most other operations take an options.hooks field whose shape is operation-specific. There is no single global TransactionCallbacks { pre, post } shape — each operation defines its own hooks interface with named per-phase slots:
import { getATAIntoETADirectDepositorFunction } from "@umbra-privacy/sdk/deposit";

const deposit = getATAIntoETADirectDepositorFunction({ client });

await deposit(destinationAddress, mint, amount, {
  hooks: {
    onValidationStart:       async () => console.log("Validating..."),
    onMintFetchComplete:     async ({ tokenProgram, hasTransferFee }) => console.log("Mint:", tokenProgram, "T22 fee:", hasTransferFee),
    onAccountFetchComplete:  async ({ userAccountExists, isSharedMode }) => console.log("Account:", { userAccountExists, isSharedMode }),
    queueComputation: {
      onPreSend:   async ({ signedTransaction }) => console.log("Sending deposit..."),
      onPostSend:  async ({ signature }) => console.log("Queued:", signature),
      onMonitorStarted: async ({ computationAccount }) => console.log("MPC monitoring:", computationAccount),
      onFinalized: async ({ callbackSignature }) => console.log("MPC callback:", callbackSignature),
    },
    onComplete: async (result) => console.log("Deposit done:", result.queueSignature),
    onError:    async ({ phase, error }) => console.error("Failed at", phase, error),
  },
});
The exact hook slot names per operation:
  • Deposit (ATAIntoETADirectDepositHooks) — top-level: onValidationStart/Complete, onMintFetchStart/Complete, onAccountFetchStart/Complete, onArciumSetupStart/Complete, onInstructionBuildStart/Complete, onComplete, onError. Per-step: queueComputation: MpcTransactionStepHooks — slots: onPreSend, onPostSend, onMonitorStarted, onProgress, onFinalized, onRentReclaimSubmitted, onRentReclaimError.
  • Withdrawal (WithdrawalHooks) — analogous shape with the same MpcTransactionStepHooks slot.
  • Conversion (ConvertToSharedHooks) — per-mint hooks plus onComplete / onError.

Usage — Burn lifecycle hooks (BurnHooks)

Burns are relayer-managed: ZK proof generation, relayer submission, polling, and the on-chain callback all happen behind a single burn() call. The burner factory exposes per-stage lifecycle hooks on the deps.hooks slot. The hooks are named per-event (not generic onPhase*):
import { getReceiverBurnableStealthPoolNoteIntoETABurnerFunction } from "@umbra-privacy/sdk/burn";

const burn = getReceiverBurnableStealthPoolNoteIntoETABurnerFunction(
  { client },
  {
    fetchBatchMerkleProof: client.fetchBatchMerkleProof!,
    zkProver,
    relayer: { submitBurn: r.submitClaim, pollBurnStatus: r.pollClaimStatus, getRelayerAddress: r.getRelayerAddress },
    hooks: {
      // Setup phase
      onKeyDerivationStart:    async () => console.log("Deriving keys..."),
      onKeyDerivationComplete: async ({ elapsedMs }) => console.log("Keys ready in", elapsedMs, "ms"),

      // Batch assembly
      onBatchAssemblyStart:    async () => console.log("Assembling batches..."),
      onBatchAssemblyComplete: async ({ batchCount, totalStealthPoolNotes }) =>
        console.log("Assembled", batchCount, "batches for", totalStealthPoolNotes, "notes"),

      // Per-batch lifecycle
      onBatchStart:                     async ({ batchIndex, batchCount }) => console.log(`Batch ${batchIndex}/${batchCount} starting`),
      onBatchZkProofGenerationStart:    async ({ batchIndex }) => console.log("Batch", batchIndex, "proving..."),
      onBatchZkProofGenerationComplete: async ({ batchIndex, elapsedMs }) => console.log("Batch", batchIndex, "proof done in", elapsedMs, "ms"),
      onBatchSubmitted:                 async ({ batchIndex, requestId }) => console.log("Batch", batchIndex, "submitted:", requestId),
      onBatchProgress:                  async ({ batchIndex, status }) => console.log("Batch", batchIndex, "status:", status),
      onBatchComplete:                  async ({ batchIndex, status, signatures }) => console.log("Batch", batchIndex, "complete:", status, signatures),

      // Terminal
      onComplete: async (result) => console.log("Burn complete:", result),
      onError:    async ({ phase, error }) => console.error("Burn error in phase", phase, ":", error),
    },
  },
);
onError’s phase is one of: "keyDerivation", "batchAssembly", "zkProofGeneration", "batchSubmission", "batchPolling".