How I built a swarm of mutually-distrusting AI agents that verify, police, and market-make real-world carbon credits on the Casper Network — including reverse-engineering Odra's storage layout so the whole stack runs without an indexer.
Carbon markets have a trust problem. Verifying a single offset project takes 2–5 years of human auditing, fraud routinely survives certification because nothing monitors projects afterward, and the whole lifecycle — issuance, pricing, retirement — lives in private registry databases.
Casper Carbon is my attempt at a different architecture: AI agents as first-class on-chain actors. Not "an LLM wrote a report," but agents with their own Casper accounts, contract-enforced permissions, on-chain reputations, and hash-committed reasoning that anyone can audit in their browser.
This post walks through the interesting engineering: the contract design, how agent separation-of-powers is enforced by the chain rather than by code, the Odra storage-layout reverse-engineering that lets both agents and the dashboard read state trustlessly, and the "verifiable AI" pattern.
Repo: github.com/harishkotra/casper-carbon
The architecture
Three agents, four contracts, one dashboard. The critical design decision: the contracts are the source of authority, not the agents.
Separation of powers, enforced by the chain
The AgentRegistry contract maps each agent's Address to exactly one typed identity:
#[odra::odra_type]
pub enum AgentType { Verifier, Marker, Compliance }
#[odra::odra_type]
pub struct AgentInfo {
pub address: Address,
pub name: String,
pub agent_type: AgentType,
pub reputation_score: u32,
pub total_verifications: u32,
pub successful_verifications: u32,
pub is_active: bool,
}
Every state-changing entry point in the project registry does a cross-contract authorization check before doing anything:
pub fn verify_project(&mut self, project_id: u32, score: u8,
credit_supply: U256, reasoning_hash: String) {
let caller = self.env().caller();
self.require_agent_auth(&caller, &AgentType::Verifier); // ← reverts otherwise
// ... status Pending → Verified, commit score + reasoning hash
self.record_agent_success(&caller, true); // ← reputation +1, on-chain
}
fn require_agent_auth(&self, address: &Address, agent_type: &AgentType) {
let registry = AgentRegistryContractRef::new(self.env(), registry_addr);
if !registry.is_authorized(address, agent_type) {
self.env().revert(Error::Unauthorized);
}
}
The consequence: the verifier runs under a secp256k1 account registered as Verifier, compliance under a separate ed25519 account registered as Compliance. A compromised verifier key physically cannot slash a project, and a compromised compliance key cannot verify one. No agent-side code enforces this — the chain does.
A war story: my seed script originally registered the same account as Verifier and then as Compliance. The second registration silently overwrote the first (one mapping entry per address), and every verify_project deploy reverted Unauthorized. The fix — one identity per agent type — turned an annoyance into the project's core security property.
Reading Odra state without an indexer
Here's the part most Casper dApps solve with an indexer or event stream. I wanted the agents and the dashboard to read contract state trustlessly, straight from global state.
The catch: Odra doesn't expose your contract's fields as named keys. Query the contract and you'll find exactly one storage-related named key: a dictionary called state. Every Var and Mapping in your module lives inside it, under derived keys.
Digging through odra-core's contract_env.rs and the odra-macros IR, the layout turns out to be:
- Each field gets a 1-based index in struct declaration order (
idx as u8 + 1in the macro). - The dictionary key is
hex(blake2b256(index_as_u32_be ++ mapping_key_bytes))— for aVar, just the index bytes; for aMapping, the index followed by the bytesrepr-serialized map key.
Reimplemented in TypeScript:
import { blake2b } from "@noble/hashes/blake2.js";
function odraKey(fieldIndex: number, mapKeyBytes?: Uint8Array): string {
const idx = new Uint8Array(4);
new DataView(idx.buffer).setUint32(0, fieldIndex, false); // big-endian
const input = mapKeyBytes
? Buffer.concat([Buffer.from(idx), Buffer.from(mapKeyBytes)])
: Buffer.from(idx);
return Buffer.from(blake2b(input, { dkLen: 32 })).toString("hex");
}
// CarbonProjectRegistry { projects: Mapping<u32, Project>, // index 1
// next_project_id: Var<u32>, ... } // index 2
const key = odraKey(1, u32LeBytes(projectId)); // projects[projectId]
That key goes into a standard state_get_dictionary_item RPC call against the contract's state URef. What comes back is a List<U8> CLValue: the raw Casper bytesrepr encoding of your Rust struct. Decoding it means walking the bytes exactly as casper-types serializes them:
function decodeProject(raw: Uint8Array) {
let o = 0;
[id, o] = readU32LE(raw, o); // u32: 4 bytes LE
[name, o] = readString(raw, o); // String: u32 length + UTF-8
// ...
const verifier = hex(raw.slice(o + 1, o + 33)); o += 33; // Address: tag + 32 bytes
const status = STATUSES[raw[o]]; o += 1; // enum: u8 discriminant
[supply, o] = readU256(raw, o); // U256: 1 length byte + N bytes LE (!)
// ...
}
That U256 line cost me an afternoon: Casper's U256 is length-prefixed and variable-width, not a fixed 32 bytes. Get it wrong and every field after it decodes as garbage.
The payoff is worth it: the dashboard's API routes decode projects, listings, and agent reputations directly from a public RPC node — no database, no indexer, no event pipeline. agents/src/test-chain-read.ts validates the whole derivation against live testnet on every run.
ABI-driven deploys
The write path had its own foot-gun: Casper entry points declare exact CL types (project_id: U32, score: U8, credit_supply: U256), and a deploy with a U512 where a U32 is expected fails at runtime. Instead of hard-coding types, the agent library introspects the contract's entry points from global state and builds arguments to match:
const argTypes = await getEntryPointArgTypes(contractHash, entryPoint); // from query_global_state
switch (argTypes.get(key)) {
case "U8": clArgs[key] = CLValue.newCLUint8(Number(val)); break;
case "U32": clArgs[key] = CLValue.newCLUInt32(Number(val)); break;
case "U256": clArgs[key] = CLValue.newCLUInt256(String(val)); break;
case "String": clArgs[key] = CLValue.newCLString(String(val)); break;
// ...
}
Wrong-typed arguments become impossible by construction — the chain's own ABI is the schema.
Verifiable AI reasoning
The pattern I'm most excited about. When the verifier scores a project with GPT-4o, it doesn't just act on the result — it commits to it:
export function storeReasoning(payload: unknown): string {
const json = JSON.stringify(payload); // the exact string
const hash = createHash("sha256").update(json).digest("hex"); // the commitment
fs.writeFileSync(path.join(STORE_DIR, `${hash}.json`), json); // the artifact
return hash; // → goes on-chain
}
The hash rides along in the verify_project deploy as reasoning_hash. The dashboard then closes the loop in the user's browser:
const text = await (await fetch(`/reasoning/${hash}.json`)).text();
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
const computed = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, "0")).join("");
const verified = computed === onChainHash; // ✓ badge, or ✗ if anyone tampered
You don't trust my server's claim about what the AI said. You verify it against the chain, byte for byte. The compliance agent uses the same pattern for fraud evidence when it slashes a project.
The agents in production terms
Each agent is a simple poll loop with real integrations — no mocks anywhere:
-
Verifier (every 60s): read pending projects from the state dictionary → fetch the project's registry data from Carbonmark v19 → GPT-4o scores methodology, additionality, permanence, leakage → if
score ≥ 60with high confidence:verify_project→activate_project→liston the marketplace. Three real deploys per project, each logged with itstestnet.cspr.livelink. -
Compliance (continuous): re-screens Verified/Active projects for fraud signals; above 70% confidence it fires
slash_projectwith the evidence hash. It has already autonomously slashed a project on testnet — with GPT-4o citing over-crediting patterns in REDD+ baselines. -
Market (every 60s): pulls the live Carbonmark spot (median across listings, $5 floor), computes each on-chain listing's spread in basis points, and
cancel_listings anything more than 50 bps off-market.
Reputation feeds back automatically: verify_project internally calls record_verification on the AgentRegistry, so an agent's track record accrues atomically with the work itself.
Lessons learned
-
Read the framework's macro source. Odra's storage layout isn't documented for external readers; the answer was in
odra-macros' IR (idx as u8 + 1) andcontract_env.rs. -
Casper's bytesrepr is simple but unforgiving. Length-prefixed
U256, taggedAddress(1 + 32 bytes),Option<T>as tag byte + payload. Write one decoder, test it against live state, reuse it everywhere. - Introspect, don't hard-code. Entry-point signatures live on-chain; use them.
- One identity per agent type is a feature. What started as a registration bug became the security model.
- Public RPC endpoints matter. Rate-limited gateways are fine for agents; a dashboard hammering dictionary reads wants a shared state-root cache, sequential reads, and a short server-side response cache (8s did it).
What's next
CSPR.click wallet integration for in-browser retirement, registry-driven CEP-18 minting so listed credits are transferable end-to-end, x402-metered satellite/news feeds for the compliance agent, and multi-verifier consensus with stake-weighted scoring.
The bigger thesis: as RWAs move on-chain, verification is the bottleneck — and the answer isn't one trusted AI, it's many mutually-distrusting agents with cryptographic identities, chain-enforced powers, and auditable reasoning. Casper's Odra framework, cross-contract calls, and cheap deploys made that pattern buildable in days.
Screenshots
Code & more: https://www.dailybuild.xyz/project/186-casper-carbon






Top comments (0)