Core Soroban smart contracts for the Cambium Protocol — a carbon credit registry, tokenization, marketplace, and retirement system built on Stellar.
Part of the Cambium Protocol organization. See the org profile for the full system architecture.
- Overview
- Contract architecture
- Contracts in this repo
- Data model
- Trust assumptions
- Repository structure
- Prerequisites
- Setup
- Building
- Testing
- Deploying
- Contract interfaces
- Events
- Security considerations
- Roadmap
- Contributing
- License
This repo contains the on-chain logic for Cambium Protocol, written in Rust and compiled to WASM for the Soroban runtime. It is responsible for:
- Minting carbon credits as tokens, gated by a valid MRV proof
- Preventing double-counting and double-selling of credits
- Enabling fractional ownership and transfer of credits
- Providing a liquidity venue (via Soroban's DEX / a custom AMM pool) for price discovery
- Recording permanent, publicly verifiable retirement (offset) events
- Verifying zero-knowledge proofs submitted by the
oracle-nodeservice, generated by circuits defined inzk-circuits
This repo does not contain the off-chain oracle logic (see oracle-node) or the proof circuit definitions (see zk-circuits) — it only contains the on-chain verifier and business logic that consumes those proofs.
Cambium Protocol is split into five cooperating contracts rather than one monolith, so that each piece can be audited, upgraded, and reasoned about independently.
┌───────────────┐ mint request ┌───────────────┐
│ ZKVerifier │◄────────────────────────│ Registry │
│ (Groth16 proof │ valid proof required │ project/vintage │
│ verification) │─────────────────────────►│ metadata │
└───────────────┘ proof valid/invalid └───────┬───────┘
│ authorizes mint
▼
┌───────────────┐
│ CreditToken │
│ (SEP-41 asset) │
└───────┬───────┘
transfer/trade │ retire
┌──────────────────────┼──────────────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Marketplace │ │ Retirement │
│ (order book / │ │ (burn + public │
│ AMM pool) │ │ attestation) │
└───────────────┘ └───────────────┘
Tracks the canonical metadata for every carbon project and vintage issued on Cambium: project ID, methodology (e.g. VM0007, ARR, biochar), geography, vintage year, registry cross-reference (Verra/Gold Standard project ID, if bridging an existing credit), and total issued/retired supply. This is the "source of truth" other contracts read from before allowing a mint.
A SEP-41-compliant fungible token contract representing carbon credits. Each token unit represents a fixed fraction of one metric ton of CO2e (configurable per deployment, default: 1 unit = 0.001 tCO2e, enabling fine-grained fractional trading). Minting is only callable by the registry contract after proof verification; it cannot be minted arbitrarily by any single key. An optional compliance allowlist can be enabled by the admin to restrict transfers/mints to addresses explicitly added via set_allowlisted — off by default.
Wraps Soroban's native BN254 host functions (available since Protocol 25 / "X-Ray") to verify Groth16 proofs submitted by oracle-node. Given a proof and its public inputs, it returns whether the proof is valid against the canonical verifying key the registry passes in (keyed by project methodology and version, bound to the project id being minted). Verifying keys are versioned per methodology and can only be updated through the registry contract's governance path (see Trust assumptions).
Provides two trading paths:
- A constant-product AMM pool (credit ↔ USDC or credit ↔ XLM) for instant liquidity and price discovery on smaller trades. Pools hold real escrowed balances: the creator's initial liquidity and every swap settle via actual token transfers against the marketplace, reserves always match the escrow, a per-pool swap fee (
fee_bps) is charged on the input, and anyone canadd_liquidity/remove_liquidity. - A limit order book for larger trades where price slippage from an AMM would be unacceptable. Orders escrow the sold asset upfront, sweep crossing resting orders at the maker price, and can be cancelled with a full escrow refund.
Both paths respect fractional amounts down to the token's minimum unit.
Burns credits permanently and writes an immutable, publicly queryable retirement record: retiring party (or a ZK-shielded reference if privacy is requested — see below), amount, project/vintage, and timestamp. This is what an auditor or the public checks to confirm a claimed offset was real and hasn't been resold.
Privacy note: retirement events (that a retirement of X tons from project Y occurred) are always public — this is intentional, per the org's design principle that environmental claims stay public. What can optionally be shielded via ZK is which specific buyer performed the retirement, while still producing a proof that a valid, authorized party did so. See zk-circuits for the membership-proof approach used here.
Simplified core structs (see registry/src/types.rs for the full definitions):
pub struct Project {
pub id: BytesN<32>,
pub methodology: Symbol, // e.g. "VM0007", "ARR", "BIOCHAR"
pub geography: Symbol,
pub external_registry_ref: Option<Bytes>, // e.g. Verra project ID, if bridged
pub verifying_key_version: u32,
}
pub struct Vintage {
pub project_id: BytesN<32>,
pub year: u32,
pub total_issued: i128,
pub total_retired: i128,
}
pub struct RetirementRecord {
pub project_id: BytesN<32>,
pub vintage_year: u32,
pub amount: i128,
pub retired_at: u64,
pub retiree: RetireeRef, // Public(Address) or Shielded(BytesN<32> nullifier)
}
// governance proposals (see `registry/src/types.rs`)
pub enum ProposalTarget {
Vkey(Symbol, BytesN<32>), // rotate a methodology's verifying key
Governance(GovernanceConfig), // replace signers / threshold / timelock
}
pub struct Proposal {
pub id: BytesN<32>,
pub target: ProposalTarget,
pub proposed_at: u64,
pub approvals: Vec<Address>,
pub executed: bool,
pub cancelled: bool,
}
pub struct Pool {
pub id: BytesN<32>,
pub credit_token: Address,
pub paired_token: Address,
pub paired_asset: Symbol,
pub credit_reserves: i128, // real escrowed balances, not bookkeeping
pub paired_reserves: i128,
pub fee_bps: u32, // swap fee in basis points
}Being explicit about this is a project design principle, not an afterthought:
| Component | Trust assumption |
|---|---|
zk-verifier proof validity |
The proof mathematically guarantees the circuit was satisfied. It does not guarantee the real-world inputs the oracle fed into the circuit were true — that's a trust assumption on oracle-node's data sources (see that repo's README). |
| Verifying key updates | Restricted to a governance multi-sig: updates require a configured number of signer approvals and a timelock delay before execution (see registry/src/governance.rs). The same flow covers rotation of the signer set itself (execute_governance_update), so no signer set can become permanently self-appointed. A compromised or malicious key update could let invalid proofs verify — this is the single highest-value attack surface in the system. |
| Bridged (wrapped) credits | If a credit is tokenized from an existing registry (Verra, Gold Standard) rather than natively issued, Cambium is only as trustworthy as that underlying registry and the custodian attesting to the wrap. This is clearly flagged per-project via external_registry_ref. |
| Oracle liveness | If oracle-node operators go offline, no new credits can be minted, but existing trading/retirement is unaffected. |
registry/ # projects, vintages, governance, mint authorization
├── src/
│ ├── lib.rs
│ ├── types.rs
│ └── governance.rs
├── credit-token/ # SEP-41 fungible credit asset + supply/allowlist
│ └── src/lib.rs
├── zk-verifier/ # Groth16 proof verification (BN254)
│ └── src/lib.rs
├── marketplace/ # AMM pool + limit order book
│ ├── src/
│ │ ├── lib.rs
│ │ ├── amm.rs
│ │ └── orderbook.rs
├── retirement/ # burn + public/enumerable retirement records
│ └── src/lib.rs
├── shared/ # common types/errors shared across contracts
│ └── src/lib.rs
├── tests/ # integration tests across multiple contracts
├── scripts/
│ ├── deploy.sh
│ └── verify-deployment.sh
├── Cargo.toml # workspace root
└── README.md
- Rust (stable, 1.79+) with the
wasm32v1-nonetarget (required for on-chain deployment) - Soroban CLI (
stellar-cli) v27+ - A funded Stellar testnet account (for deployment/testing against live testnet)
rustup target add wasm32v1-noneInstall the latest stellar-cli binary from https://github.com/stellar/stellar-cli/releases or build from source:
cargo install --locked stellar-cligit clone https://github.com/cambium-protocol/contracts.git
cd contracts
cargo buildBuild all contracts to optimized WASM:
stellar contract buildIndividual contract builds:
stellar contract build --package cambium-credit-tokenWASM artifacts are written to target/wasm32v1-none/release/. Optimize before deployment to reduce on-chain fees:
stellar contract optimize --wasm target/wasm32v1-none/release/cambium_credit_token.wasmNote: Do not use
cargo build --target wasm32-unknown-unknownfor contracts intended for deployment — usestellar contract buildinstead. Thestellar contract buildcommand targetswasm32v1-noneand applies the compilation flags the Soroban runtime requires. Thewasm32-unknown-unknowntarget produces WASM that the VM will reject at deploy time.
Unit tests per contract:
cargo test -p registry
cargo test -p credit-token
cargo test -p zk-verifier
cargo test -p marketplace
cargo test -p retirementFull workspace test suite:
cargo test --workspaceIntegration tests (multi-contract flows — e.g. full mint → trade → retire lifecycle) live in tests/ and use the Soroban local sandbox:
cargo test -p integration-testsWe target >90% branch coverage on the registry, credit-token, and retirement contracts, and 100% coverage on any authorization/access-control path. Run coverage locally with:
cargo tarpaulin --workspace --out HtmlThe contracts below are the canonical testnet deployment as of 2026-07-14. These addresses are the ones downstream services (sdk-js, oracle-node, web-app) should use.
| Contract | Testnet address | Explorer |
|---|---|---|
credit-token |
CBRBMYB6UTJEMMSBQQPYHAIO5QWJAT4EBPIFTEEB6MRY6ZZD5NS5KY36 |
↗ |
zk-verifier |
CDHHVK26VAEP4APPELQLJQLZUKMCDSXGBWT7K6V7L7T6CHHRDY2MUAD7 |
↗ |
registry |
CBSLLVCIZBXKPHY73PN5DVHQKNGK4FAZBXMQLKZCJABABUX5OQGPHC43 |
↗ |
marketplace |
CAKXZQTCVDSGVF2BU5FY636O4TDCAX5UJCWYGQKDKMOA5QNBDKPXZ5S7 |
↗ |
retirement |
CDIHLUARSMSYU27QRKXBWVK5HXIJRUAQ3SYQYCK3MZ2UKMCRB275H3G5 |
↗ |
These are also recorded in DEPLOYMENTS.md and in deployed-addresses.testnet.json (written by the deploy script).
# Create and fund deployer identity (testnet only — Friendbot is called automatically by the script)
stellar keys generate test
# Deploy all five contracts in dependency order, wire them together, and
# write deployed-addresses.testnet.json
./scripts/deploy.sh testnetUse the provided script for a one-shot ordered deployment plus cross-contract wiring:
./scripts/deploy.sh testnetThis writes deployed contract IDs to deployed-addresses.<network>.json, which sdk-js and oracle-node both consume.
Mainnet deployment requires a completed independent security audit (see Security considerations) and multi-sig-controlled deployer keys. Do not deploy unaudited contracts to mainnet with real value.
Abbreviated public interfaces (see each contract's lib.rs for full signatures and doc comments):
// registry
fn register_project(env: Env, admin: Address, project: Project) -> Result<(), Error>;
fn request_mint(env: Env, project_id: BytesN<32>, vintage_year: u32,
amount: i128, proof: Proof) -> Result<(), Error>;
// governance-gated protocol updates (multi-sig + timelock)
fn propose_update(env: Env, signer: Address,
target: ProposalTarget) -> Result<BytesN<32>, Error>;
fn propose_vkey_update(env: Env, signer: Address, methodology: Symbol,
new_key: BytesN<32>) -> Result<BytesN<32>, Error>;
fn propose_governance_update(env: Env, signer: Address,
config: GovernanceConfig) -> Result<BytesN<32>, Error>;
fn approve_update(env: Env, signer: Address, proposal_id: BytesN<32>)
-> Result<u32, Error>; // returns total approvals
fn execute_vkey_update(env: Env, proposal_id: BytesN<32>) -> Result<VkeyState, Error>;
fn execute_governance_update(env: Env, proposal_id: BytesN<32>) -> Result<(), Error>;
fn cancel_update(env: Env, signer: Address, proposal_id: BytesN<32>) -> Result<(), Error>;
fn get_proposal(env: Env, proposal_id: BytesN<32>) -> Result<Proposal, Error>;
// credit-token (SEP-41 standard interface, plus:)
fn mint(env: Env, to: Address, amount: i128) -> Result<(), Error>; // registry-only caller
fn enable_allowlist(env: Env, enabled: bool) -> Result<(), Error>; // admin-only, off by default
fn set_allowlisted(env: Env, address: Address, allowed: bool) -> Result<(), Error>;
fn set_metadata(env: Env, decimals: u32, name: Bytes, symbol: Bytes) -> Result<(), Error>;
fn decimals(env: Env) -> u32; fn name(env: Env) -> String; fn symbol(env: Env) -> String;
fn total_supply(env: Env) -> i128;
// zk-verifier
fn verify(env: Env, proof: Proof, public_inputs: Vec<BytesN<32>>,
project_id: BytesN<32>, vkey_version: u32, vkey_key: BytesN<32>)
-> Result<bool, Error>;
// marketplace
fn create_pool(env: Env, creator: Address, pool_id: BytesN<32>,
config: PoolConfig) -> Result<Pool, Error>;
fn swap(env: Env, trader: Address, pool_id: BytesN<32>, amount_in: i128,
min_amount_out: i128) -> Result<i128, Error>;
fn add_liquidity(env: Env, provider: Address, pool_id: BytesN<32>,
credit_amount: i128, paired_amount: i128) -> Result<(), Error>;
fn remove_liquidity(env: Env, provider: Address, pool_id: BytesN<32>,
credit_amount: i128) -> Result<(i128, i128), Error>;
fn place_limit_order(env: Env, trader: Address, side: OrderSide, amount: i128,
price: i128, pool_id: BytesN<32>,
paired_token: Address) -> Result<BytesN<32>, Error>;
fn cancel_order(env: Env, trader: Address, order_id: BytesN<32>) -> Result<(), Error>;
fn get_order(env: Env, order_id: BytesN<32>) -> Result<Order, Error>;
// retirement
fn retire(env: Env, from: Address, project_id: BytesN<32>,
vintage_year: u32, amount: i128, shield: bool,
nullifier: BytesN<32>) -> Result<RetirementRecord, Error>;
fn get_retirement(env: Env, id: BytesN<32>) -> Result<RetirementRecord, Error>;
fn total_retirements(env: Env) -> u32;
fn get_retirement_ids(env: Env, project_id: BytesN<32>) -> Vec<BytesN<32>>;
fn get_retirements_by_project(env: Env, project_id: BytesN<32>) -> Vec<RetirementRecord>;All state-changing calls emit Soroban events for indexing (used by web-app and any block explorer integration):
| Event topic | Emitted by | Payload |
|---|---|---|
transfer |
credit-token |
(from, to, amount) |
approve |
credit-token |
(from, spender, amount) |
mint |
credit-token |
(admin, recipient, amount) |
burn |
credit-token |
(admin, holder, amount) |
pool_created |
marketplace |
(credit_token, paired_token, paired_asset, initial_credit, initial_paired, fee_bps) |
swap |
marketplace |
(trader, amount_in, amount_out) |
retire |
retirement |
(project_id, vintage_year, amount, retiree_or_shielded_ref) — caller omitted when shielded |
vkey_updated |
registry |
(methodology, new_key_version) |
governance_updated |
registry |
() — emitted after a signer-set/config rotation executes |
- No unaudited mainnet deployment. This codebase has not yet undergone an independent third-party audit. Track audit status in
SECURITY.md. - Verifying key governance is the highest-value target. A malicious or compromised verifying-key update could allow forged proofs to mint uncapped credits. This path uses a multi-sig with a timelock (see
registry/src/governance.rs); review this code first in any audit. - Reentrancy: Soroban's execution model differs from EVM reentrancy patterns, but cross-contract calls (e.g.
registry→credit-token) are still checked for state-consistency ordering (checks-effects-interactions applied even where classic reentrancy isn't possible). - Integer overflow: all token math uses checked arithmetic (
checked_add,checked_mul); overflow returns an explicitError::Overflowrather than panicking or wrapping. - Report vulnerabilities privately — see
SECURITY.md. Do not open a public GitHub issue for security-sensitive findings.
Completed as of 2026-08-14 (testnet):
- All five contracts deployed, initialized, and cross-wired on Stellar testnet (see Deploying)
- Full mint → trade → retire lifecycle tests pass end-to-end in the Soroban local sandbox
- AMM constant-product swap with slippage protection, real token escrow and settlement, configurable per-pool
fee_bps, andadd_liquidity/remove_liquidity - SEP-41 token metadata (
decimals/name/symbol, admin-updatable) and on-chaintotal_supply - Mints bound to the canonical, governance-approved verifying key for the project's methodology (
request_mintrejects missing/stale keys;zk-verifierbinds proofs to the project id) - Retirement records stored, enumerable per project (
get_retirement_ids/get_retirements_by_project), with collision-proof record ids; public retirement events; burning verified against the credit-token supply - Multi-sig + timelock governance for verifying-key updates and signer-set rotation in
registry(propose_update/approve_update/execute_*/cancel_update) - Limit order book in
marketplacewith upfront escrow, crossing-order sweep at maker price, partial fills, and cancel/refund - Shielded retirement (
retirement::retirewithshield=true) — records only a nullifier, rejects replay of a spent nullifier, and omits the caller from events - Compliance allowlist on
credit-token(enable_allowlist/set_allowlisted) — optional KYC/allowlist gating on transfers, off by default
Deferred — not yet implemented:
- Real Groth16 (BN254) proof verification wired to
zk-circuitsv1 methodology circuits — thezk-verifierinterface is stable and bound to canonical keys, but the current implementation is a mock that accepts structurally valid proofs whose public inputs match the project - STARK/RISC Zero verifier path for methodologies requiring larger, off-chain-heavy computation
- External audit (firm TBD) — required before mainnet deployment
- Formal verification of
registrygovernance andcredit-tokenmint authorization paths
See CONTRIBUTING.md. In short: fork, branch, write tests for any new contract logic, run cargo fmt and cargo clippy --all-targets -- -D warnings before opening a PR.