DocsGuides
Gating a program on an attestation
A gated program executes an action only if a Blindseal attestation for that exact action verifies and is consumed in the same transaction.
01
The one rule
What makes this sound
The action fields you forward to the verifier must come from what your program is about to execute — your own id, your instruction discriminator, the signer you debit, the recipient, the mint, the amount — never from anything the agent hands you.
02
CPI verify_and_consume
Add blindseal-verify with the cpi feature and call it before the action. The example below is the shipped transfer-gate program, a native-SOL transfer.
use blindseal_verify::{cpi::accounts::VerifyAndConsume, ActionArgs, program::BlindsealVerify};
let disc: [u8; 8] = <crate::instruction::GatedTransfer as anchor_lang::Discriminator>::DISCRIMINATOR
.try_into().unwrap();
let action = ActionArgs {
chain_id: 1, // Solana mainnet (the only chain id)
target_program: crate::ID, // ourselves
source_authority: ctx.accounts.source.key(), // the signer we debit
instruction: disc, // our discriminator
recipient: ctx.accounts.recipient.key(),
mint: Pubkey::default(), // native SOL
amount,
nonce, // from the attestation
};
blindseal_verify::cpi::verify_and_consume(
CpiContext::new(ctx.accounts.verify_program.to_account_info(), VerifyAndConsume {
config: ctx.accounts.config.to_account_info(),
policy_root: ctx.accounts.policy_root.as_ref().map(|a| a.to_account_info()),
nullifier: ctx.accounts.nullifier.to_account_info(),
payer: ctx.accounts.source.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
}),
proof, public_inputs, action,
)?;
// …then the action. If it fails, the whole tx reverts and the nullifier stays unconsumed.
system_program::transfer(/* source → recipient, amount */)?;Type the verifier account as Program<'info, BlindsealVerify> so a caller cannot substitute another program for the CPI.
03
What the verifier checks, in order
- not paused
detectorVersionis an allowed vkey- if allowlist mode: the policy root is registered
expirySlot ≥the current slot- the action hash recomputed from your fields (+ the committed values + the nonce) equals the public one —
ActionHashMismatchotherwise - the nullifier matches and has not been consumed —
NullifierAlreadyConsumedon replay - the Groth16 proof verifies (sp1-solana)
- the Nullifier PDA is created (payer = your signer) and
Consumedis emitted
Each step fails with its own error code. The five wrong-field cases all surface as ActionHashMismatch: the proof binds one hash over all of them, so the program cannot name which field diverged.
04
Client side
import { buildVerifyIx, actionArgsFrom, submit } from 'blindseal-sdk';
// compose into your own transaction (gated program)…
const ix = buildVerifyIx(attestation, actionArgsFrom(attestation), { payer: wallet.publicKey });
// …or submit directly (no gated program): verifies on chain and consumes the nullifier
const sig = await submit(attestation, { wallet }); // refuses any RPC that is not mainnet-betaBudget about 230k compute units for the verify step plus your own logic; set the limit explicitly with a ComputeBudget instruction (the default 200k is not enough). Measured figures live in docs/performance.md.
05
Accounts
config PDA(["config"]) read
policy_root PDA(["policy", policyRoot]) read, optional (pass the program id for None)
nullifier PDA(["nullifier", nullifier]) writable, must not exist
payer signer, writable pays the Nullifier PDA rent
program merZNYKpKwwX4CGgzvma3jPvLfrMTkZcb1cSXUKANcb