Port of the EVM Protocol Adapter to Solana, using RISC0 proving backend.
solana-protocol-adapter/
└── solana-pa-prototype/ # Solana PA implementation
The RISC0 Groth16 verifier programs are automatically cloned from devnet during testing (no local build required).
- Nix with flakes enabled (
nix --version) - Supported host platforms for the pinned Agave toolchain:
x86_64-linux,x86_64-darwin,aarch64-darwin
# Clone the repository
git clone https://github.com/anoma/solana-protocol-adapter
cd solana-protocol-adapter
# Enter the pinned development environment
nix --extra-experimental-features 'nix-command flakes' develop
# Build and run tests
cd solana-pa-prototype
./scripts/dev.sh anchor-testThat's it! The script will:
- Ensure toolchain and dependencies are available in the Nix shell
- Build the Solana programs
- Start a local validator with RISC0 verifier programs cloned from devnet
- Run the full test suite
The integration suite runs in one of two proof modes:
- real (default): fixtures carry Groth16 aggregation proofs (selector
0x73c457ba), verified on-chain by the devnet-cloned RISC0 groth16 verifier. Regenerating these fixtures requires full proving (minutes, container runtime):./scripts/dev.sh gen-fixtures tests/fixtures/batch_groth16.json. - mock (
--mode mock/PA_TEST_MODE=mock): fixtures carry mock seals (selector0xffffffff) accepted only by the localnet-onlymock-verifierprogram, which the validator's syntheticVerifierEntryaccount registers in the RISC0 router at genesis. The transactions are byte-identical to the real fixtures except the seal; generation executes the circuits without proving and takes seconds:./scripts/dev.sh gen-fixtures --mock tests/fixtures/mock/batch_groth16.json.
The PA program is identical in both modes — each PA instance pins one
verifier selector at initialize, so deployed PAs (pinned to the Groth16
selector) can never accept mock seals. Mock fixture regeneration recipes:
each real-fixture command plus --mock, output under tests/fixtures/mock/
(the imported AnomaPay fixture uses fixture-gen mockify instead, since its
proving inputs are not in this repo).
| Command | Description |
|---|---|
./scripts/dev.sh anchor-test |
Build programs and run integration tests (real Groth16 proofs) |
./scripts/dev.sh anchor-test --mode mock |
Same suite against mock proofs (localnet-only mock verifier) |
./scripts/dev.sh shell |
Open an interactive shell in the Nix dev environment |
./scripts/dev.sh anchor-build |
Build Anchor programs only |
./scripts/dev.sh test |
Run Rust unit tests |
./scripts/dev.sh validator |
Start the local validator |
./scripts/dev.sh clean |
Remove local validator/test artifacts |
./scripts/dev.sh release-build |
Build the production binaries (verifies dev-only instructions are absent) |
./scripts/dev.sh <op> --cluster <c> |
Cluster operations (deploy, upgrade, init, estop, status, balance, close-pdas, teardown) against localnet/devnet/mainnet — see scripts/ops.sh for flags |
If you encounter issues, do a complete rebuild:
# Remove local validator/test state
./scripts/dev.sh clean
# Run tests
./scripts/dev.sh anchor-testTo update pinned Nix dependencies:
nix --extra-experimental-features 'nix-command flakes' flake updateFor iterative development, use the shell command:
./scripts/dev.sh shellInside the shell:
cd solana-pa-prototype
# Build programs
anchor build
# Run tests (start validator separately first)
yarn run ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts'To start the validator in another terminal:
cd solana-pa-prototype
./scripts/dev.sh validatorA forwarder is a Solana program that the PA calls via CPI after proof verification. Forwarders execute side effects (token transfers, state updates, etc.) and return output that the PA verifies against the proof.
Your forwarder must implement a forward_call instruction:
use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::set_return_data;
#[program]
pub mod my_forwarder {
use super::*;
pub fn forward_call(
ctx: Context<ForwardCall>,
logic_ref: [u8; 32], // Resource's logic verifying key
input: Vec<u8>, // Arbitrary input from the RM transaction
) -> Result<()> {
// Your logic here...
// Return output via set_return_data (max 1024 bytes)
let output: Vec<u8> = /* your output */;
set_return_data(&output);
Ok(())
}
}
#[derive(Accounts)]
pub struct ForwardCall<'info> {
// Your required accounts...
}-
Instruction discriminator: The PA calls using Anchor's discriminator:
sha256("global:forward_call")[..8]=0x9faae00afd696cde -
Instruction data format:
[discriminator: 8 bytes] [logic_ref: 32 bytes] [input_len: 4 bytes, little-endian u32] [input: N bytes] -
Output via return data: Use
set_return_data(&output). The PA reads this immediately after CPI and verifies it matchesexpected_outputfromLogicVerifierInputs.app_data.external_payload. Solana caps return data at 1024 bytes — forwarders that need to surface more must commit a digest in return data and place the full payload elsewhere (e.g. an event or PDA).
This tutorial demonstrates the PA's external call mechanism using the block-time-forwarder example program. You'll run tests, observe the CPI flow in logs, see what happens when verification fails, and then create your own forwarder.
The forwarder compares an expected timestamp (embedded in the RM transaction) against Solana's current clock time:
| Return Value | Meaning |
|---|---|
0 |
expected < current (LT) |
1 |
expected == current (EQ) |
2 |
expected > current (GT) |
The fixture uses timestamp -1 (before Unix epoch), so against any current time it returns 0 (LT).
cd solana-pa-prototype
./scripts/dev.sh anchor-testFind the test "accepts a valid Groth16 batch aggregation tx" and look for these log lines:
BlockTimeForwarder: forward_call invoked
logic_ref: [...]
expected_time: -1
current_time: [current unix timestamp]
result: LT (expected < current)
return_data set: [0]
This shows:
- The PA called the forwarder via CPI
- The forwarder decoded
-1from the 8-byte input - It compared against the current clock
- It returned
0viaset_return_data - The PA verified this matched
expected_outputfrom the proof
Find the test "rejects a tampered tx". The logs show:
Proof verification failed
Why it fails:
- The Groth16 proof commits to a specific journal digest
- Tampering the transaction changes the digest
- The verifier rejects the mismatched proof
The full settlement sequence visible in the passing test logs:
- TxData upload —
txdataInitallocates account,txdataWritestreams chunks - Proof verification — CPI to
groth_16_verifier, journal digest computed from transaction bytes - External call — CPI to
block-time-forwarder, return data captured and compared - Commitment update — new commitment appended to the on-chain tree
- Nullifier marking — PDA created for each consumed nullifier (prevents double-spend)
The forwarder implements a single instruction, forward_call:
pub fn forward_call(
ctx: Context<ForwardCall>,
logic_ref: [u8; 32], // Resource's logic key (for access control)
input: Vec<u8>, // 8 bytes: expected timestamp as i64 LE
) -> Result<()> {
let expected_time = i64::from_le_bytes(input.try_into()?);
let current_time = ctx.accounts.clock.unix_timestamp;
let result = match expected_time.cmp(¤t_time) {
Ordering::Less => 0,
Ordering::Equal => 1,
Ordering::Greater => 2,
};
set_return_data(&[result]);
Ok(())
}The PA's CPI call passes logic_ref and input extracted from the RM transaction. After the call, it reads return data and compares against expected_output from LogicVerifierInputs.app_data.external_payload.
External calls live in LogicVerifierInputs.app_data.external_payload within the RM transaction:
pub struct SolanaExternalCall {
pub program_id: [u8; 32], // Forwarder program ID
pub instruction_data: Vec<u8>, // Passed as `input` to forward_call
pub expected_output: Vec<u8>, // Must match return data
pub output_mode: OutputMode, // ReturnData (only variant)
}The fixture generator (tools/fixture-gen) encodes this structure with the forwarder's program ID, a timestamp, and the expected comparison result.
The Solana PA intentionally mirrors the EVM PA trust model:
- Aggregation proof verification is bound to per-LVI journal bytes re-derived from
LogicVerifierInputs.app_dataviaLogicInstance::to_journal()— a hand-rolled risc0-serde encoder inarm_corematching EVM'sRiscZeroUtils.toJournal. - External call execution and output checks read the same
LogicVerifierInputs.app_data.external_payload. - Because (1) and (2) share that field, any mutation of
app_dataflows into the aggregation digest and invalidates the Groth16 proof — no duplicated wire field can diverge.
The test provides the forwarder and its dependencies via remaining_accounts:
const allRemainingAccounts = [
...nullifierPdas, // Writable PDAs for nullifier marking
{ pubkey: blockTimeForwarderId, isWritable: false, isSigner: false },
{ pubkey: SYSVAR_CLOCK_PUBKEY, isWritable: false, isSigner: false },
];The PA iterates through external calls, consuming accounts from this list for each CPI.
-
Scaffold the program:
cd solana-pa-prototype anchor new my-forwarder -
Implement
forward_callinprograms/my-forwarder/src/lib.rs:use anchor_lang::prelude::*; use anchor_lang::solana_program::program::set_return_data; declare_id!("..."); // Generated after first build #[program] pub mod my_forwarder { use super::*; pub fn forward_call( ctx: Context<ForwardCall>, logic_ref: [u8; 32], input: Vec<u8>, ) -> Result<()> { // Your logic here let output = vec![/* result bytes */]; set_return_data(&output); Ok(()) } } #[derive(Accounts)] pub struct ForwardCall<'info> { // Accounts your forwarder needs }
-
Build and sync program ID:
anchor build -p my-forwarder solana-keygen pubkey target/deploy/my_forwarder-keypair.json # Update declare_id!() with this value, then rebuild -
Update
fixture-gento encode aSolanaExternalCallwith your forwarder's program ID, input, and expected output. -
Update tests to include your forwarder's program and required accounts in
remaining_accounts.
Clients submit RM transactions to the PA for settlement.
-
Upload RM transaction (if >1232 bytes):
// Initialize TxData account await program.methods.txdataInit(uploadId, capacity, expiresSlot) .accounts({ txData, authority, systemProgram }) .rpc(); // Write chunks (max ~700 bytes per chunk) for (let offset = 0; offset < payload.length; offset += 700) { await program.methods.txdataWrite(uploadId, offset, chunk) .accounts({ txData, authority }) .rpc(); }
-
Settle:
await program.methods.settleFromTxdata(uploadId) .accounts({ paState, txData, authority, systemProgram, newRootMarker, // required: marker PDA of the post-settlement root verifierRouterProgram, router: routerPda, verifierEntry: verifierEntryPda, verifierProgram: groth16VerifierId, }) .remainingAccounts([ // Nullifier marker PDAs (one per consumed resource) ...nullifierPdas.map(pubkey => ({ pubkey, isWritable: true, isSigner: false })), // External call accounts (forwarder program + its required accounts) { pubkey: forwarderProgramId, isWritable: false, isSigner: false }, { pubkey: forwarderAccount1, isWritable: false, isSigner: false }, // ... more forwarder accounts ]) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), ]) .rpc();
const PA_STATE_SEED = Buffer.from("pa_state");
const NULLIFIER_SEED = Buffer.from("nullifier");
const TX_DATA_SEED = Buffer.from("tx_data");
const ROOT_MARKER_SEED = Buffer.from("root");
// PA state (singleton)
const [paState] = PublicKey.findProgramAddressSync([PA_STATE_SEED], programId);
// Nullifier marker (one per consumed nullifier)
const [nullifierPda] = PublicKey.findProgramAddressSync(
[NULLIFIER_SEED, paState.toBuffer(), nullifierBytes],
programId
);
// TxData account
const uploadIdLe = Buffer.alloc(8);
uploadIdLe.writeBigUInt64LE(BigInt(uploadId));
const [txData] = PublicKey.findProgramAddressSync(
[TX_DATA_SEED, authority.toBuffer(), uploadIdLe],
programId
);
// Root marker
const [rootMarker] = PublicKey.findProgramAddressSync(
[ROOT_MARKER_SEED, paState.toBuffer(), rootBytes],
programId
);The PA expects remaining_accounts in this order:
- Nullifier PDAs (N accounts, writable) - One per consumed nullifier in the transaction
- External call segments - For each external call:
- Forwarder program account (executable)
- Accounts required by that forwarder
- Historical root markers (optional, read-only) - For transactions anchored to historical roots. Position-independent: validity is checked by scanning the whole list.
The new root marker PDA is NOT part of remaining_accounts: it is the
named new_root_marker account of settle/settle_from_txdata (required,
writable). Settlement rejects with RootPdaMismatch unless its address is
the marker PDA of the post-settlement root.
External calls are part of the transaction wire format — see
solana-pa-prototype/docs/INTEGRATION.md,
"External call encoding".
Fixtures contain pre-generated RM transactions with valid Groth16 proofs. Required for testing because proof generation is expensive (~2 hours per fixture).
{
"format": "solana-pa-fixture-v1",
"aggregation_strategy": "batch",
"aggregation_proof_type": "groth16",
"selector": "0x73c457ba",
"tx_b64": "<base64 encoded RM transaction>",
"tx_tampered_b64": "<base64 encoded tampered transaction>",
"consumed_nullifiers_b64": ["<base64>", ...]
}Proofs are dispatched to the AnomaPay workers queue. Set QUEUE_BASE_URL and QUEUE_AUTH_TOKEN in your environment first.
cd solana-pa-prototype
cargo run --locked --manifest-path tools/fixture-gen/Cargo.toml --release -- tests/fixtures/batch_groth16.jsonGenerate the mismatch fixture used by the ExternalCallOutputMismatch test:
cargo run --locked --manifest-path tools/fixture-gen/Cargo.toml --release -- --output-mismatch tests/fixtures/batch_groth16_mismatch.jsonfixture-gen builds passthrough-logic-guest in Docker during build/startup.
Docker is required because the guest is compiled with the RISC0 guest toolchain (cargo +risc0 and the RISC-V C toolchain) provided by the risczero/risc0-guest-builder image.
Keep Docker running, and no extra cargo feature flags are required.
Fixtures embed the block_time_forwarder program ID. If that ID changes, fixtures must be regenerated. The test script detects this automatically.
The local test validator automatically clones RISC0 verifier programs from devnet. This is configured in Anchor.toml:
[test.validator]
url = "https://api.devnet.solana.com"
[[test.validator.clone]]
address = "BetEAE4npinksQBxvqUN1KkCVjYFJywWao45MSWtp5yg" # Verifier Router
[[test.validator.clone]]
address = "2Yfa83Lzbn71ie3J1KQRiNQz1qHnvVm8gkBCpXZQ7ajD" # Groth16 Verifier
[[test.validator.clone]]
address = "9ZJmYSYaYq38GfwQMsEw5gkzfr94Vbzw6Nv53yQuCv2S" # Router PDA (initialized state)
[[test.validator.clone]]
address = "4ktbrXwBXZMoND5qb3J6abS1m8KqwUtCjjDBebJ4vqey" # Verifier Entry PDA (groth16 selector registered)cd solana-pa-prototype
./scripts/dev.sh validatorThe validator is configured to clone the RISC0 verifier programs from devnet on startup.
cd solana-pa-prototype
solana config set --url http://127.0.0.1:8899
anchor deploy --provider.cluster http://127.0.0.1:8899Deployment, initialization, emergency stop, and retirement procedures for
devnet/mainnet live in
solana-pa-prototype/docs/OPERATIONS.md.
Ensure flakes are enabled:
nix --extra-experimental-features 'nix-command flakes' developThe pinned Agave v3.0.13 release does not publish aarch64-unknown-linux-gnu binaries.
Use an x86_64-linux host (or macOS) for this repo's pinned Nix workflow.
The validator may have crashed or port 8899 is in use:
# Check if port is in use
lsof -i :8899
# Clean up and retry
./scripts/dev.sh clean
./scripts/dev.sh anchor-testProgram ID in source doesn't match keypair. The anchor-test.sh script syncs IDs automatically. If you see this error, try:
./scripts/dev.sh clean
./scripts/dev.sh anchor-testEnsure you're on a branch that uses git dependencies (not local paths):
git checkout feature/remove-submodule-with-pkg-deps
./scripts/dev.sh clean
./scripts/dev.sh anchor-testfixture-gen needs Docker specifically for guest compilation (passthrough-logic-guest) via the RISC0 guest-builder image.
Then verify Docker is available, and that fixture-gen can compile and start:
docker --version
cargo check --locked --manifest-path tools/fixture-gen/Cargo.toml
timeout 8 tools/fixture-gen/target/debug/fixture-gen tools/fixture-gen/target/tmp-fixture-check.jsonThe first build downloads and compiles many dependencies (~10-20 minutes). Subsequent builds reuse the local Nix/cargo caches and are much faster.