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.

rust
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

  1. not paused
  2. detectorVersion is an allowed vkey
  3. if allowlist mode: the policy root is registered
  4. expirySlot ≥ the current slot
  5. the action hash recomputed from your fields (+ the committed values + the nonce) equals the public one — ActionHashMismatch otherwise
  6. the nullifier matches and has not been consumed — NullifierAlreadyConsumed on replay
  7. the Groth16 proof verifies (sp1-solana)
  8. the Nullifier PDA is created (payer = your signer) and Consumed is 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

ts
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-beta

Budget 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

text
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