Escelit/cambuim-contracts

Carbon credits you can actually verify — not just trust. Cambium mints on-chain only when backed by ZK proofs, trades through a real AMM + order book, and permanently burns credits on retirement. Built on Stellar/Soroban. Testnet live; audit + full ZK verification in progress.

★ 0Forks 0RustGitHub ↗Compare

README

cambium-contracts

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.


Table of contents


Overview

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-node service, generated by circuits defined in zk-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.


Contract architecture

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)   │
                     └───────────────┘                         └───────────────┘

Contracts in this repo

1. registry

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.

2. credit-token

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.

3. zk-verifier

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).

4. marketplace

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 can add_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.

5. retirement

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.


Data model

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
}

Trust assumptions

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.

Repository structure

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

Prerequisites

  • Rust (stable, 1.79+) with the wasm32v1-none target (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-none

Install the latest stellar-cli binary from https://github.com/stellar/stellar-cli/releases or build from source:

cargo install --locked stellar-cli

Setup

git clone https://github.com/cambium-protocol/contracts.git
cd contracts
cargo build

Building

Build all contracts to optimized WASM:

stellar contract build

Individual contract builds:

stellar contract build --package cambium-credit-token

WASM 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.wasm

Note: Do not use cargo build --target wasm32-unknown-unknown for contracts intended for deployment — use stellar contract build instead. The stellar contract build command targets wasm32v1-none and applies the compilation flags the Soroban runtime requires. The wasm32-unknown-unknown target produces WASM that the VM will reject at deploy time.


Testing

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 retirement

Full workspace test suite:

cargo test --workspace

Integration tests (multi-contract flows — e.g. full mint → trade → retire lifecycle) live in tests/ and use the Soroban local sandbox:

cargo test -p integration-tests

We 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 Html

Deploying

Testnet — canonical addresses

The 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).

Running a fresh deployment

# 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 testnet

Use the provided script for a one-shot ordered deployment plus cross-contract wiring:

./scripts/deploy.sh testnet

This writes deployed contract IDs to deployed-addresses.<network>.json, which sdk-js and oracle-node both consume.

Mainnet

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.


Contract interfaces

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>;

Events

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

Security considerations

  • 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 explicit Error::Overflow rather than panicking or wrapping.
  • Report vulnerabilities privately — see SECURITY.md. Do not open a public GitHub issue for security-sensitive findings.

Roadmap

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, and add_liquidity / remove_liquidity
  • SEP-41 token metadata (decimals/name/symbol, admin-updatable) and on-chain total_supply
  • Mints bound to the canonical, governance-approved verifying key for the project's methodology (request_mint rejects missing/stale keys; zk-verifier binds 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 marketplace with upfront escrow, crossing-order sweep at maker price, partial fills, and cancel/refund
  • Shielded retirement (retirement::retire with shield=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-circuits v1 methodology circuits — the zk-verifier interface 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 registry governance and credit-token mint authorization paths

Contributing

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.

License

Apache License 2.0

Contributors

ogaziedaniel80-droiddevOgaziNnamdiCyberaristotle224cyberdocs120devprom6vicistar-star

Issues