Skip to main content

Soroban Contract API

The OpenZKTool Groth16 verifier contract deployed on Stellar Soroban provides on-chain zero-knowledge proof verification.

Contract Address

Stellar Testnet:

CBPBVJJW5NMV4UVEDKSR6UO4DRBNWRQEMYKRYZI3CW6YK3O7HAZA43OI

View on Stellar Expert →

Contract Interface

verify

Verifies a Groth16 zero-knowledge proof.

Function Signature

pub fn verify(
env: Env,
proof: Vec<u8>,
public_inputs: Vec<u8>,
vk: VerificationKey
) -> bool

Parameters

ParameterTypeDescription
envEnvSoroban environment
proofVec<u8>Serialized Groth16 proof
public_inputsVec<u8>Public input signals
vkVerificationKeyVerification key structure

Returns

TypeDescription
booltrue if proof is valid, false otherwise

Example Usage

use soroban_sdk::{contract, contractimpl, Env, Vec, Bytes};

#[contract]
pub struct MyDApp;

#[contractimpl]
impl MyDApp {
pub fn check_kyc(env: Env, proof: Vec<u8>) -> bool {
let verifier_id = BytesN::from_array(
&env,
&hex!("CBPBVJJW5NMV4UVEDKSR6UO4DRBNWRQEMYKRYZI3CW6YK3O7HAZA43OI")
);

// Invoke the verifier contract
let result: bool = env.invoke_contract(
&verifier_id,
&symbol_short!("verify"),
(&proof,).into_val(&env)
);

result
}

pub fn gated_action(env: Env, user: Address, proof: Vec<u8>) {
// Require valid proof before allowing action
if !Self::check_kyc(env.clone(), proof) {
panic!("Invalid KYC proof");
}

// Proceed with action...
log!(&env, "KYC verified for user: {}", user);
}
}

Proof Format

Proofs must be encoded in the following binary format:

Groth16 Proof Structure

struct Proof {
a: G1Point, // 64 bytes
b: G2Point, // 128 bytes
c: G1Point, // 64 bytes
}

struct G1Point {
x: [u8; 32], // Field element
y: [u8; 32], // Field element
}

struct G2Point {
x: ([u8; 32], [u8; 32]), // Fq2 element
y: ([u8; 32], [u8; 32]), // Fq2 element
}

Total Proof Size: 256 bytes (constant)

Serialization

Proofs are serialized in big-endian format:

[A.x (32 bytes)] [A.y (32 bytes)]
[B.x.c0 (32)] [B.x.c1 (32)] [B.y.c0 (32)] [B.y.c1 (32)]
[C.x (32 bytes)] [C.y (32 bytes)]

Public Inputs Format

Public inputs are encoded as field elements:

struct PublicInputs {
signals: Vec<[u8; 32]> // Each signal is 32 bytes
}

For the KYC circuit:

[kycValid (32 bytes)]

Verification Key Format

The verification key contains cryptographic parameters:

struct VerificationKey {
alpha: G1Point,
beta: G2Point,
gamma: G2Point,
delta: G2Point,
ic: Vec<G1Point>, // IC points for public inputs
}

Note: The verification key is embedded in the contract during deployment.

Gas Costs

Approximate Soroban operation costs:

OperationGas (Operations)Time
Proof deserialization~10,000~10ms
Pairing computation~150,000~150ms
Final verification~40,000~40ms
Total~200,000~200ms

Cost in XLM: ~0.0002 XLM per verification (at current rates)

Error Handling

The contract returns errors for invalid inputs:

Error Codes

CodeDescriptionResolution
InvalidProofFormatProof serialization errorCheck proof encoding
InvalidPublicInputsPublic inputs mismatchVerify input count/format
PairingCheckFailedCryptographic verification failedRegenerate proof
OutOfGasInsufficient gas providedIncrease gas limit

Example Error Handling

use soroban_sdk::{contractimpl, Env, Vec};

#[contractimpl]
impl MyContract {
pub fn safe_verify(env: Env, proof: Vec<u8>) -> Result<bool, Error> {
let verifier = get_verifier_address();

match env.try_invoke_contract(&verifier, &symbol_short!("verify"), (&proof,)) {
Ok(result) => Ok(result),
Err(e) => {
log!(&env, "Verification failed: {}", e);
Err(Error::VerificationFailed)
}
}
}
}

Integration Patterns

Pattern 1: Stateless Verification

Verify proofs without storing any state:

#[contractimpl]
impl Verifier {
pub fn verify_once(env: Env, proof: Vec<u8>) -> bool {
let verifier = get_verifier_id();
env.invoke_contract(&verifier, &symbol_short!("verify"), (&proof,))
}
}

Use Case: One-time access checks

Pattern 2: Proof Caching

Store verification results to avoid re-verification:

#[contractimpl]
impl Verifier {
pub fn verify_and_cache(env: Env, user: Address, proof: Vec<u8>) -> bool {
// Check cache first
if let Some(cached) = env.storage().get(&user) {
return cached;
}

// Verify and cache
let result = Self::verify_once(env.clone(), proof);
env.storage().set(&user, &result);

result
}
}

Use Case: Repeated access within a session

Pattern 3: Batch Verification

Verify multiple proofs in a single transaction:

#[contractimpl]
impl BatchVerifier {
pub fn verify_batch(env: Env, proofs: Vec<Vec<u8>>) -> Vec<bool> {
let verifier = get_verifier_id();
let mut results = Vec::new(&env);

for proof in proofs.iter() {
let valid: bool = env.invoke_contract(
&verifier,
&symbol_short!("verify"),
(&proof,)
);
results.push_back(valid);
}

results
}
}

Use Case: Multi-user verification

Deployment Guide

Deploy Your Own Verifier

# Build the contract
cd contracts
cargo build --target wasm32-unknown-unknown --release

# Optimize WASM
wasm-opt --strip-debug -Oz \
target/wasm32-unknown-unknown/release/verifier.wasm \
-o verifier-optimized.wasm

# Deploy to Soroban
soroban contract deploy \
--wasm verifier-optimized.wasm \
--source YOUR_SECRET_KEY \
--network testnet

Deploy Output

Contract deployed successfully!
Contract ID: YOUR_CONTRACT_ID
Transaction: https://stellar.expert/explorer/testnet/tx/HASH

Testing

Unit Tests

#[test]
fn test_valid_proof() {
let env = Env::default();
let contract = VerifierClient::new(&env, &contract_id);

let proof = generate_test_proof();
let result = contract.verify(&proof);

assert_eq!(result, true);
}

#[test]
fn test_invalid_proof() {
let env = Env::default();
let contract = VerifierClient::new(&env, &contract_id);

let invalid_proof = vec![0u8; 256];
let result = contract.verify(&invalid_proof);

assert_eq!(result, false);
}

Integration Tests

# Test on testnet
npm run test:integration

Security Considerations

1. Proof Replay Attacks

Problem: Same proof can be used multiple times.

Solution: Add nonce or timestamp to public inputs:

pub fn verify_with_nonce(env: Env, proof: Vec<u8>, nonce: u64) -> bool {
// Check nonce hasn't been used
if env.storage().has(&nonce) {
return false;
}

let valid = verify(env.clone(), proof);

if valid {
env.storage().set(&nonce, &true);
}

valid
}

2. Front-Running

Problem: Attacker sees proof in mempool and submits it first.

Solution: Bind proof to sender address:

pub fn verify_for_sender(env: Env, proof: Vec<u8>) -> bool {
let sender = env.invoker();

// Proof must include sender in public inputs
let valid = verify_with_sender(env, proof, sender);

valid
}

3. DoS via Invalid Proofs

Problem: Spamming invalid proofs wastes gas.

Solution: Implement rate limiting:

pub fn verify_with_rate_limit(env: Env, user: Address, proof: Vec<u8>) -> bool {
let attempts = env.storage().get::<_, u32>(&user).unwrap_or(0);

if attempts >= MAX_ATTEMPTS {
panic!("Rate limit exceeded");
}

let valid = verify(env.clone(), proof);

if !valid {
env.storage().set(&user, &(attempts + 1));
}

valid
}

Monitoring & Observability

Event Logging

#[contractimpl]
impl Verifier {
pub fn verify_with_logs(env: Env, proof: Vec<u8>) -> bool {
log!(&env, "Verification started");

let start = env.ledger().timestamp();
let result = verify(env.clone(), proof);
let duration = env.ledger().timestamp() - start;

log!(&env, "Verification completed: {} ({}ms)", result, duration);

env.events().publish(
(symbol_short!("verify"),),
(result, duration)
);

result
}
}

Metrics

Track verification metrics:

struct Metrics {
total_verifications: u64,
successful_verifications: u64,
failed_verifications: u64,
average_gas_used: u64,
}

Next Steps


Need help? GitHub Discussions