Skip to content

feat(compression/cnft-vault): add pinocchio example - #722

Open
MarkFeder wants to merge 2 commits into
solana-foundation:mainfrom
MarkFeder:compression-cnft-vault-pinocchio
Open

feat(compression/cnft-vault): add pinocchio example#722
MarkFeder wants to merge 2 commits into
solana-foundation:mainfrom
MarkFeder:compression-cnft-vault-pinocchio

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

Ports compression/cnft-vault to Pinocchio — the second of the three compression/ examples, after #719.

Program

A vault PDA ([b"cNFT-vault"]) holds compressed NFTs by being their leaf owner, and signs mpl-bubblegum Transfer CPIs to send them back out. Two instructions, dispatched on a leading discriminator byte:

  • withdraw_cnft — one cNFT to one recipient
  • withdraw_two_cnfts — two cNFTs, possibly from different trees, in a single instruction

Both share one transfer_cnft helper. As in #719 the transfer arguments are already in bubblegum's wire order, so the CPI data is the discriminator followed by the arguments verbatim, and the variable-length proof means the account list is built into a fixed-size stack array and passed with invoke_signed_with_bounds instead of the const-generic invoke_signed.

One deliberate difference from the Anchor version. withdraw_two_cnfts receives both proofs concatenated in the account tail and splits them using lengths from the instruction data. The Anchor version takes both lengths but ignores the second (_proof_2_length), so an overstated first length would push the split past the first proof and let the second transfer read accounts the first already consumed. This checks that the two lengths sum to exactly the proof accounts supplied, before doing anything else. There's a test for it.

The vault and tree-authority PDAs are both rederived on-chain, and the CPI always targets the hardcoded bubblegum id rather than the passed program account.

Tests

The Anchor variant is in .ghaignore because its tests need devnet and a DAS indexer. This one runs entirely under LiteSVM against the mainnet-dumped bubblegum, account-compression and noop programs:

  1. three trees, one vault-owned cNFT each — each root checked against a locally recomputed leaf
  2. withdraw_cnft from the first tree
  3. withdraw_two_cnfts from the other two
  4. a mismatched proof split is rejected with InvalidInstructionData

Giving each cNFT its own tree keeps every proof to the empty-node path, so the test needs no merkle tree implementation of its own. Each withdrawal is verified by recomputing the expected post-transfer leaf — a transfer rewrites the leaf with the recipient as both owner and delegate — and comparing it to the tree's own change log, rather than just checking the transaction succeeded.

Verification

cargo build-sbf, isolated and workspace clippy -D warnings, cargo fmt --check --all, tsc --noEmit, prettier --check, and the 4-test LiteSVM suite all pass locally.

Ports the Anchor cnft-vault example to Pinocchio: a vault PDA holds compressed
NFTs by being their leaf owner, and signs mpl-bubblegum `Transfer` CPIs to send
them back out — one at a time, or two from different trees in one instruction.

Both proofs of `withdraw_two_cnfts` arrive concatenated in the account tail, so
the instruction data carries each proof's length to split them. Unlike the
Anchor version, which ignores the second length, this checks that the two
lengths account for exactly the proof accounts supplied — otherwise an
overstated first length would let the second transfer read accounts the first
already consumed.

The proof is variable-length, so the CPI account list is built into a
fixed-size stack array and passed with `invoke_signed_with_bounds` rather than
the const-generic `invoke_signed`.

Unlike the Anchor variant, whose tests need devnet and a DAS indexer, the
LiteSVM suite runs entirely locally against the mainnet-dumped bubblegum,
account-compression and noop programs. Each cNFT gets its own tree so every
proof stays the empty-node path, and each withdrawal is checked by recomputing
the expected leaf off-chain against the tree's own state.
@MarkFeder
MarkFeder requested a review from dev-jodee as a code owner September 1, 2026 19:01
@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a Pinocchio implementation of the compressed-NFT vault, supporting single and paired withdrawals through Bubblegum CPIs.

  • Adds the on-chain instruction dispatcher, PDA validation, proof splitting, and shared transfer helper.
  • Adds LiteSVM integration tests and setup tooling for the required external programs.
  • Registers the program in the Cargo workspace and adds its TypeScript dependencies and configuration.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
compression/cnft-vault/pinocchio/program/src/instructions/transfer.rs Builds and signs Bubblegum transfer CPIs with a 30-node proof bound that now covers the protocol maximum.
compression/cnft-vault/pinocchio/program/src/instructions/withdraw_cnft.rs Validates the vault PDA and forwards one compressed-NFT transfer and its proof.
compression/cnft-vault/pinocchio/program/src/instructions/withdraw_two_cnfts.rs Validates concatenated proof lengths before splitting them and executing two atomic transfers.
compression/cnft-vault/pinocchio/program/src/processor.rs Dispatches the two supported instructions from a leading discriminator byte.
compression/cnft-vault/pinocchio/tests/test.ts Exercises single and paired withdrawals and verifies rejection of inconsistent proof lengths.

Reviews (2): Last reviewed commit: "cnft-vault: raise the proof cap to the p..." | Re-trigger Greptile

A 24-node cap could reject a valid proof: proofs are `max_depth - canopy_depth`
nodes and SPL Account Compression allows a max_depth of 30, so a canopy-less
deep tree needs all 30 — and address lookup tables make that transaction fit.
At 30 the bound can no longer reject anything valid; it only keeps the CPI
account list on the stack.
@MarkFeder

Copy link
Copy Markdown
Contributor Author

@amilz could you take a look at this one when you get a chance?

No open review threads left on it, so it is ready for maintainer review. It is one of 23 open Pinocchio ports I have up — they are independent and self-contained, so they can be reviewed and merged in any order: https://github.com/solana-developers/program-examples/pulls/MarkFeder

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant