Skip to content

feat(solana): open the next batch at dispatch and batch 0 at initialize - #4387

Draft
Eikix wants to merge 3 commits into
feature/solanafrom
solana/batcher-pending-batch
Draft

Eikix wants to merge 3 commits into
feature/solanafrom
solana/batcher-pending-batch

Conversation

@Eikix

@Eikix Eikix commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

dispatch now opens the next batch in the same instruction, and initialize_batcher opens batch 0. A batcher therefore always has exactly one Pending batch. Before this change, a bare dispatch left the batcher with no pending batch until someone paid for a separate open_batch.

open_batch is deleted, along with its previous_batch account and the errors PreviousBatchStillPending, PreviousBatchMismatch and BatchIndexMismatch. The open logic is now one internal helper (instructions/open_batch.rs) that both instructions call.

Research (EVM first)

  • EVM rolls over inside the permissionless dispatch: dispatchBatch calls the base dispatch and then opens the next batch, and the constructor opens the first batch.
    • Private pointers:
      • zama-ai/confidential-defi@fc5e876e04 contracts/src/batcher/VaultBatcherConfidential.sol:240-254 and :114;
      • the same rollover at 5980d9ab contracts/src/VaultBatcherConfidential.sol:281-291.
    • Public base: OpenZeppelin/openzeppelin-confidential-contracts@4a4f6c71 contracts/finance/BatcherConfidential.sol:167-168 and :468-470.
  • Solana keeps EVM's outcome: one pending batch at all times, opened by whoever dispatches.
    • The Solana form is one instruction with the next batch's accounts. It is not a keeper composing two instructions, because only the program can guarantee the property. A third party's bare dispatch would otherwise leave a gap.
  • dispatch is the only way out of Pending, because cancel_dispatch and settle require a dispatched batch. The invariant therefore holds by construction.

Changes

  • Program (confidential-batcher)
    • dispatch(authority_funding_lamports) takes the next batch's accounts. These are the batch at batcher.next_batch_index, its authority, its two confidential token accounts and stores, its two SPL underlyings, the payout mint and its underlying, the SPL token program, and the payout mint's HCU accounts.
    • The burn runs first, then the open. Deny records come in this order: the burn's join mint, then the next batch's join mint, then its payout mint.
    • initialize_batcher(..., authority_funding_lamports) takes the same set for batch 0 and becomes an FHE-executing instruction.
    • Every check that open_batch made still applies on both paths, through the helper: both mints pinned to the batcher, and both underlying mints pinned to their confidential mints.
    • BATCH_SEED is exported as an IDL constant. No instruction declares the batch's index as a seed any more: dispatch derives it from account data, which Codama cannot read. The codegen therefore types the batch PDA from that constant, as it already does for the host's hcuBlockMeter, and pda-golden.test.ts checks it. The codegen also links dispatch's three nextBatch* defaults to the batch PDAs keyed on nextBatch.
  • Demo
    • buildDispatchBatchInstruction takes roots, batchIndex and authorityFundingLamports, and derives both batches.
    • openBatchForBatcher is replaced by buildInitializeBatcherInstruction, which the seeder calls once per batcher.
    • prepareNextBatch only reads the pending batch and runs the reclaim pass.
    • settleBatch requires batchIndex. Its default, "the current batch", can no longer be a dispatched batch.
    • getCurrentBatch returns the pending batch. Its guard for "no batch opened yet" was dead and is removed.
  • Seed: initialize_batcher is sent through sendFheTransaction with the host policy, and the explicit first open is gone.
  • e2e (deposit-arc)
    • It reads the dispatched batch by index.
    • It asserts that batch N+1 is Pending right after dispatch.
    • It passes batchIndex to settleBatch.
    • It drops the start-up branches that handled a dispatched or finished current batch, which can no longer happen.
  • Docs
  • demo-dapp/tsconfig.node.json: src/demoClient.ts is now the first root.
    • The dapp, the SDK and the zama-host client each install @solana/* 8.4.0, and tsc keeps whichever copy it loads first.
    • HEAD passed only because batchProvisioning.ts imported @fhevm/sdk/solana on its first line. Removing that import made tsc pick the zama-host client's nested copy, and declaration emit failed with TS2883.
    • The duplicate installs are existing debt and are not fixed here.

Side effects

  • dispatch now also needs the payout mint to be usable. A denied payout mint, or a full HCU meter on either mint, blocks dispatch. Pending quits still work, so no funds are locked.
  • Whoever dispatches pays the next batch's rent and funds its authority. That includes a permissionless third party, just as EVM's dispatcher pays for the new batch's storage.
    • Rent is about 0.0059 SOL: the 131-byte batch plus two 165-byte SPL accounts.
    • Funding the authority is the dispatcher's choice. The unspent remainder goes back to the join mint authority through reclaim_batch_authority, not to the dispatcher.
  • Too little funding makes the open fail, so the whole dispatch reverts and the batch stays Pending (pinned).
  • Anyone can create the next batch's token accounts before the dispatch, and the dispatch still succeeds, because token-account initialization is idempotent (pinned).
  • In the demo, findExistingDeposit's fallback (with no saved position) reads only the pending batch. Right after a dispatch it no longer finds a join in the dispatched batch. The saved position remains the main path. This fallback already missed such joins once the next batch had been opened.

Measured against the estimate

The worst case has the deny list on and both mints' HCU accounts supplied.

Estimate Measured
dispatch transaction size ~1,350–1,450 B 1,419 B of 4,096 (fit test fits one v1 transaction with every witness)
Account keys ~37–38 38 of 64
dispatch CU ~450–460k 422,446, up from 211,730 (deposit snapshot, CI run 38156066804)
redeem_dispatch CU — 392,428, up from 183,231
initialize_batcher CU — 251,605 (deposit), 250,098 (redeem)

The estimate was the old dispatch plus the old open_batch (211,730 + 248,175 = 459,905 CU). The folded instruction costs about 37k CU less than that sum. The redeem direction follows the same pattern: 183,231 + 246,676 = 429,907, against 392,428 measured. Both stay well under the 1.4M CU transaction limit.

Tests

  • New Mollusk tests
    • mollusk_initialize_batcher_opens_batch_zero;
    • mollusk_a_batcher_has_exactly_one_pending_batch: scans every batch after each step of initialize → dispatch → settle → dispatch → cancel → dispatch;
    • mollusk_dispatch_opens_the_next_batch: a join lands in N+1 while N settles and pays out;
    • mollusk_dispatch_funds_the_next_batch_authority: the exact funding with a non-default amount, and 0 reverts with N still Pending;
    • mollusk_dispatch_rejects_another_payout_mint;
    • mollusk_dispatch_rejects_a_next_batch_at_another_index;
    • mollusk_dispatch_accepts_precreated_next_batch_token_accounts.
  • Updated: the re-dispatch assertions now name the batcher's actual next batch, so they still reach BatchNotPending rather than failing on seeds first.
  • Demo: the dispatch and initialize builders get derivation and deny-record checks, HCU slot checks, and the worst-case fit test.
  • Golden test: pda-golden.test.ts checks the initializeBatcher and dispatch defaults under the default and an overridden program address.

Mutations (rule 15): each mutation was built into the program and the batcher suite was run against it.

Mutation Killed by
dispatch returns before opening the next batch mollusk_dispatch_opens_the_next_batch, mollusk_a_batcher_has_exactly_one_pending_batch, ..._funds_the_next_batch_authority, ..._accepts_precreated_... and every flow that dispatches (34 failing, the 2 snapshot tests included)
initialize_batcher returns before opening batch 0 mollusk_initialize_batcher_opens_batch_zero, mollusk_a_batcher_has_exactly_one_pending_batch and every lifecycle test (42 failing, the 2 snapshot tests included)
the helper funds a fixed 1 SOL instead of the argument mollusk_dispatch_funds_the_next_batch_authority (only)
the payout-mint pin is removed mollusk_dispatch_rejects_another_payout_mint (only)

Ran locally

  • Rust:
    • cargo fmt --check;
    • clippy with -D warnings;
    • batcher_mollusk: 42 pass, and the 2 cost-snapshot tests fail locally by design;
    • check-zama-host-idl.sh, dead-surface-check.sh, check-path-filters.sh.
  • JS:
    • codegen:solana:check;
    • demo-dapp check, lint and vitest: 232 pass;
    • test-suite check and bun test src/solana demo: 185 pass;
    • deploy check.
  • Not run locally: the solana-e2e demo phase (seed and deposit-arc). CI runs it.

Generated (rule 18): the IDL, AUTHORITY.md (unchanged) and the Codama clients come from sync-zama-host-idl.sh and the SDK codegen. The cost snapshots come from the CI artifact of run 38156066804. That run regenerates in update mode, which never deletes keys, so the artifact still held the open_batch and redeem_open_batch profiles. Those two keys were removed with jq; a local regeneration from empty measurements gives the same key set.

Non-goals

  • the keeper service;
  • closing batch rent (fhevm-internal#2093);
  • any change to join, quit, settle, claim, cancel or reclaim;
  • concurrent dispatches, or EVM's guard against dispatching while the previous batch is in flight. Solana batches have their own token accounts and pending burn, so no shared balance can be swept.
  • compatibility (pre-production).

Rejected alternatives

  • The keeper sends [dispatch(N), open_batch(N+1)] in one transaction. This needs no program change, but a third party's bare dispatch would still leave no pending batch. Only the program can make the property hold.
  • A hand-written findBatchPda in the demo. Rejected: the PDA stays generated, with the seed prefix taken from the IDL constant and checked by the golden test.

dispatch now opens the next batch in the same instruction, as EVM's
dispatchBatch does, and initialize_batcher opens batch 0, so a batcher
always has exactly one pending batch. open_batch, its previous_batch
account and its three errors are removed; the open logic is one internal
helper both instructions call.

The demo, seed and e2e follow: the dispatch builder derives both batches
from the roots and the batch index, the seeder initializes each batcher
with batch 0, and settleBatch takes the batch index it settles.
@cla-bot cla-bot Bot added the cla-signed label Oct 11, 2026
Eikix added 2 commits October 11, 2026 18:34
Measurements come from the solana-cost-snapshots artifact of run
38156066804. The run regenerates in update mode, which never deletes
keys, so the artifact still carried the open_batch and
redeem_open_batch profiles whose test no longer exists. Those two keys
are removed with jq. A local regeneration from empty measurements
produces the same key set.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant