An advanced developer tutorial for verifying carbon-credit retirement, preventing duplicate claims, and protecting commercially sensitive sustainability data.

Introduction

Carbon markets have a problem that is easy to underestimate: proving that a carbon credit has actually been retired and ensuring that the same credit isn't used to support multiple environmental claims.

Imagine a company purchases 10,000 carbon credits to offset part of its annual emissions. It wants to prove that those credits were legitimately retired, but it may not want to publicly disclose:

  • The exact quantity purchased.
  • The price paid.
  • The identity of its suppliers.
  • Which business unit received the allocation.
  • Its internal emissions and sustainability strategy.

At the same time, auditors and business partners need reliable evidence that the credits exist, were retired, and haven't been claimed twice.

A conventional database can manage this information, but independent parties must trust whoever operates it. A public blockchain can provide shared records, but publishing every transaction and commercial relationship creates confidentiality problems.

This is an interesting use case for Oasis.

In this tutorial, we'll design CarbonVault, a confidential carbon-credit retirement and allocation system using:

  • Oasis Sapphire for confidential smart contract execution and state.
  • Oasis ROFL for off-chain verification in a Trusted Execution Environment.
  • Solidity for the retirement registry.
  • TypeScript for verification and application logic.
  • External carbon registry data for authoritative retirement evidence.

The central idea is to separate three responsibilities:

  1. Verify the underlying carbon credit and its retirement.
  2. Prevent the same credit from being allocated or claimed more than once within CarbonVault.
  3. Allow authorized parties to verify claims without exposing every commercial detail.

This is a reference architecture, not a replacement for established carbon registries or their retirement procedures.

1. Understand the problem before writing code

Carbon credits generally represent a quantified reduction or removal of greenhouse gas emissions.

Credits are issued through programs and registries that maintain information about projects, methodologies, vintages, serial numbers, transfers, and retirement.

A blockchain application cannot simply declare that a credit is legitimate because someone submitted a transaction.

It needs to establish:

  • Which registry issued the credit?
  • Which project generated it?
  • Which vintage does it belong to?
  • What serial-number range identifies it?
  • Has the registry marked it as retired?
  • Who is entitled to make the associated environmental claim?

There is also an important distinction between retirement and allocation.

Retirement is the action recorded by the authoritative carbon registry that removes credits from circulation according to its rules.

Allocation is an organization's internal decision about which business unit, product, or reporting period receives the benefit of those retired credits.

CarbonVault will manage the second layer and independently record evidence of the first.

It will not attempt to replace the authoritative registry.

2. System architecture

The application has four main participants:

  • Credit holder: submits evidence of credit ownership and retirement.
  • Carbon registry: provides authoritative information about issued and retired credits.
  • ROFL verifier: validates registry evidence and checks the requested allocation.
  • Sapphire contract: maintains confidential allocation records and prevents duplicate registrations within the application.

Architecture diagram

flowchart TD A["Credit Holder"] --> B["CarbonVault Frontend"] B --> C["Encrypted Submission"] C --> D["Oasis Sapphire"] D --> E["Confidential Credit Registry"] F["ROFL Application"] --> G["Registry Verification Adapter"] G --> H["External Carbon Registry"] H --> G G --> F F --> I["Validate Retirement Evidence"] I --> J["Calculate Credit Nullifier"] J --> K["Authenticated Sapphire Call"] K --> D D --> L["Duplicate Allocation Check"] L --> M["Confidential Allocation Record"] M --> N["Authorized Verification"]

The division of responsibilities is important.

Sapphire maintains the application state and authorization rules. ROFL performs the off-chain verification. The external carbon registry remains the source of truth for the underlying credit retirement.

The blockchain should not be treated as proof that an external registry made a particular decision unless the verification process establishes a trustworthy connection to that registry.

3. Repository structure

We'll use a monorepo so the contract, verifier, and frontend can evolve together.

carbon-vault/ │ ├── contracts/ │ ├── src/ │ │ └── CarbonVault.sol │ ├── test/ │ │ └── CarbonVault.test.ts │ └── hardhat.config.ts │ ├── verifier/ │ ├── src/ │ │ ├── worker.ts │ │ ├── registryAdapter.ts │ │ ├── evidenceVerifier.ts │ │ ├── nullifier.ts │ │ └── sapphireClient.ts │ ├── test/ │ │ ├── evidence.test.ts │ │ └── duplicate.test.ts │ ├── Dockerfile │ └── package.json │ ├── frontend/ │ ├── src/ │ │ ├── components/ │ │ ├── pages/ │ │ └── lib/ │ │ ├── wallet.ts │ │ └── carbonVault.ts │ └── package.json │ ├── shared/ │ ├── types/ │ │ └── credit.ts │ └── schemas/ │ └── evidence.ts │ ├── docs/ │ ├── architecture.md │ ├── threat-model.md │ └── deployment.md │ ├── .env.example └── README.md 

The main components are:

Component Responsibility
CarbonVault.sol Registration, authorization, and allocation state
registryAdapter.ts Communication with external registries
evidenceVerifier.ts Validation of retirement evidence
nullifier.ts Generation of unique credit identifiers
worker.ts ROFL application workflow
carbonVault.ts Frontend contract integration

The repository deliberately separates evidence verification from contract interaction. This makes it easier to test the verification logic independently.

4. Define a unique credit identity

Before implementing the contract, we need a reliable way to identify a credit or a group of credits.

A registry record might contain:

{ "registry": "example-registry", "projectId": "PROJECT-1042", "vintage": 2023, "serialStart": 100000, "serialEnd": 100999, "quantity": 1000, "status": "retired" } 

For this example, the serial range represents 1,000 credits.

We can derive a canonical credit identifier from the registry, project, vintage, and serial range:

[
N = H(R \,|\, P \,|\, V \,|\, S_0 \,|\, S_1)
]

Where:

  • (N) is the credit nullifier.
  • (H) is a cryptographic hash.
  • (R) is the registry identifier.
  • (P) is the project identifier.
  • (V) is the vintage.
  • (S_0) is the first serial number.
  • (S_1) is the final serial number.

A nullifier is a deterministic identifier that lets the application recognize the same underlying credit range when it is submitted again.

It is not an encryption mechanism. If the inputs are predictable, someone may be able to guess them from the hash.

For a production implementation, the canonical encoding must be unambiguous. Do not concatenate arbitrary strings without defining their boundaries and encoding.

A TypeScript implementation could use ABI encoding:

import { AbiCoder, keccak256 } from "ethers"; type CreditIdentity = { registryId: string; projectId: string; vintage: bigint; serialStart: bigint; serialEnd: bigint; }; const coder = AbiCoder.defaultAbiCoder(); export function createCreditNullifier( credit: CreditIdentity ): string { const encoded = coder.encode( ["string", "string", "uint256", "uint256", "uint256"], [ credit.registryId, credit.projectId, credit.vintage, credit.serialStart, credit.serialEnd ] ); return keccak256(encoded); } 

The same canonicalization rules must be used by every component.

An important detail: overlapping ranges

Hashing the entire serial range prevents an identical range from being submitted twice.

However, it does not automatically prevent someone from submitting overlapping ranges.

For example:

  • Submission A: serials 1000 to 1099.
  • Submission B: serials 1050 to 1149.

These are different hashes, but 50 credits appear in both.

A production implementation therefore needs either:

  1. Per-credit nullifiers, where each serial number is registered individually.
  2. A reliable interval-overlap detection mechanism.
  3. A registry-specific identifier and verification model that establishes unique credit ownership and retirement.

For a first implementation, per-credit nullifiers are easier to reason about, although large ranges may be expensive to process.

For this tutorial, we'll use a range commitment for the demonstration and explicitly treat overlap detection as a required production extension.

5. Implement the Sapphire smart contract

The contract needs to manage three things:

  • Who is allowed to register verified retirement records.
  • Which credit identifiers have already been registered.
  • Which organization or reporting period receives an allocation.

Here's a simplified Solidity implementation.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract CarbonVault { enum Status { None, Registered, Allocated, Revoked } struct CreditRecord { bytes32 evidenceHash; bytes32 policyVersion; address provider; uint64 registeredAt; Status status; } struct Allocation { bytes32 creditNullifier; bytes32 beneficiaryCommitment; bytes32 reportingPeriod; uint256 quantity; bool exists; } address public admin; mapping(address => bool) public verifiers; mapping(bytes32 => CreditRecord) private creditRecords; mapping(bytes32 => Allocation) private allocations; modifier onlyAdmin() { require(msg.sender == admin, "Not admin"); _; } modifier onlyVerifier() { require(verifiers[msg.sender], "Not verifier"); _; } event CreditRegistered(bytes32 indexed creditId); event CreditAllocated(bytes32 indexed allocationId); constructor() { admin = msg.sender; } function setVerifier( address verifier, bool enabled ) external onlyAdmin { verifiers[verifier] = enabled; } function registerCredit( bytes32 creditId, bytes32 evidenceHash, bytes32 policyVersion ) external onlyVerifier { require( creditRecords[creditId].status == Status.None, "Credit already registered" ); creditRecords[creditId] = CreditRecord({ evidenceHash: evidenceHash, policyVersion: policyVersion, provider: msg.sender, registeredAt: uint64(block.timestamp), status: Status.Registered }); emit CreditRegistered(creditId); } function allocateCredit( bytes32 allocationId, bytes32 creditId, bytes32 beneficiaryCommitment, bytes32 reportingPeriod, uint256 quantity ) external onlyVerifier { require( creditRecords[creditId].status == Status.Registered, "Credit unavailable" ); require( !allocations[allocationId].exists, "Allocation already exists" ); require(quantity > 0, "Invalid quantity"); allocations[allocationId] = Allocation({ creditNullifier: creditId, beneficiaryCommitment: beneficiaryCommitment, reportingPeriod: reportingPeriod, quantity: quantity, exists: true }); creditRecords[creditId].status = Status.Allocated; emit CreditAllocated(allocationId); } function getCreditStatus( bytes32 creditId ) external view returns (Status) { return creditRecords[creditId].status; } } 

What this contract actually guarantees

Within this simplified application:

  • Only authorized verifiers can register credit records.
  • An identical credit identifier cannot be registered twice.
  • A registered credit cannot be allocated again after its status changes.
  • An allocation identifier cannot be reused.

It does not independently prove that a carbon registry retired the credits.

It also does not prevent overlapping serial ranges, enforce partial allocations, or implement a complete revocation and dispute process.

Those are important distinctions. A contract can enforce its own state transitions without proving the correctness of the external evidence used to trigger them.

Why deploy on Sapphire?

Sapphire is an EVM-compatible confidential ParaTime.

Its confidential execution and state capabilities are useful here because the application may need to protect:

  • Internal allocation relationships.
  • Beneficiary information.
  • Reporting-period assignments.
  • Commercial evidence references.
  • Other information that organizations do not want publicly visible.

The private keyword in Solidity is not sufficient to provide confidentiality on an ordinary EVM chain.

Sapphire's confidentiality model must be used with the appropriate client integration, encrypted transactions, and carefully designed data flows.

Also remember that ordinary contract events are public by default. The example emits opaque identifiers, not personal or commercial evidence. If event payload confidentiality is required, investigate Sapphire's encrypted events functionality.

Documentation:

6. Build the external evidence verifier

Now we need to connect the application to an external carbon registry.

This is one of the most important parts of the system.

The verifier must establish that the supplied credit information corresponds to a genuine registry record and that the relevant credits have been retired.

The exact process depends on the registry.

A registry might provide:

  • An authenticated API.
  • Signed records.
  • Downloadable retirement certificates.
  • A public search interface.
  • A documented verification process.

Not every registry provides cryptographically signed API responses. If the verifier relies on an ordinary API response, it is relying on that registry endpoint and the application's authenticated connection to it.

We should make that trust assumption explicit.

Here's a simplified interface:

export type RegistryEvidence = { registryId: string; projectId: string; vintage: number; serialStart: bigint; serialEnd: bigint; quantity: bigint; retirementReference: string; beneficiary: string; }; export type VerifiedEvidence = { valid: boolean; retirementConfirmed: boolean; evidenceDigest: string; policyVersion: string; }; export interface RegistryAdapter { verifyRetirement( evidence: RegistryEvidence ): Promise<VerifiedEvidence>; } 

The application can then implement a registry-specific adapter.

export async function verifyRetirement( evidence: RegistryEvidence, registry: RegistryAdapter ): Promise<VerifiedEvidence> { const result = await registry.verifyRetirement(evidence); if (!result.valid) { throw new Error("Invalid registry evidence"); } if (!result.retirementConfirmed) { throw new Error("Credits are not confirmed as retired"); } if (evidence.quantity <= 0n) { throw new Error("Invalid credit quantity"); } const expectedQuantity = evidence.serialEnd - evidence.serialStart + 1n; if (expectedQuantity !== evidence.quantity) { throw new Error("Serial range and quantity mismatch"); } return result; } 

This code validates basic consistency and the result returned by the adapter.

It does not implement an actual carbon registry integration. The adapter must perform the real authentication, response validation, signature checks where supported, and registry-specific business logic.

For production, it should also check whether the retirement record is associated with the correct beneficiary and whether the registry permits the proposed downstream claim.

7. Use ROFL for controlled verification

The next step is to run the verification workflow in a ROFL application.

Why not put everything into the smart contract?

Because the verification process may require:

  • External HTTPS requests.
  • API credentials.
  • Registry-specific signature validation.
  • Evidence normalization.
  • Complex policy logic.
  • Secure handling of confidential information.

These operations are more appropriate for an off-chain service.

A ROFL application provides a framework for running application logic inside a TEE on Oasis infrastructure.

Verification sequence

sequenceDiagram participant H as Credit Holder participant S as Sapphire participant R as ROFL Worker participant C as Carbon Registry H->>S: Submit encrypted registration request S->>R: Request verification R->>C: Retrieve retirement evidence C-->>R: Registry response R->>R: Validate evidence and policy R->>R: Calculate credit nullifier R->>S: Submit authenticated verification S->>S: Check duplicate registration S-->>H: Registration result

The ROFL application should authenticate itself using the supported ROFL-to-Sapphire authentication mechanism.

Do not assume that a hard-coded worker address proves that the caller is a genuine ROFL application.

The application identity, attestation assumptions, and authorized contract interaction must be configured according to the official workflow.

Worker implementation

The following is an illustrative orchestration function.

import { createCreditNullifier } from "./nullifier"; import { verifyRetirement } from "./evidenceVerifier"; export async function processCreditRegistration( evidence: RegistryEvidence, registry: RegistryAdapter, sapphire: { registerVerifiedCredit: ( creditId: string, evidenceHash: string, policyVersion: string ) => Promise<void>; } ) { // Step 1: Verify the authoritative registry record. const verification = await verifyRetirement( evidence, registry ); // Step 2: Calculate a deterministic credit identifier. const creditId = createCreditNullifier({ registryId: evidence.registryId, projectId: evidence.projectId, vintage: BigInt(evidence.vintage), serialStart: evidence.serialStart, serialEnd: evidence.serialEnd }); // Step 3: Submit the verification result. // This adapter must use the documented ROFL // authentication mechanism in a real deployment. await sapphire.registerVerifiedCredit( creditId, verification.evidenceDigest, verification.policyVersion ); return { creditId, verified: true }; } 

Notice that the worker does not simply trust the information supplied by the frontend.

It independently asks the registry adapter to validate the retirement evidence.

The Sapphire adapter is intentionally abstract. Its implementation must use the appropriate authenticated ROFL integration rather than a generic transaction signer.

What ROFL adds

ROFL can help protect the execution environment used for verification.

It does not guarantee:

  • That the external registry is honest.
  • That the registry API has not returned incorrect information.
  • That the application code implements the correct carbon accounting rules.
  • That the credit represents a genuine environmental benefit.
  • That an organization is legally entitled to make a particular claim.

These are separate verification and governance questions.

Documentation:

https://docs.oasis.io/build/rofl/

8. Design the allocation workflow

After registration, an organization can allocate retired credits to a particular reporting period or internal business unit.

For example:

A company retires 1,000 credits and wants to allocate them as follows:

Business unit Allocation
European operations 600
North American operations 250
Research division 150
Total 1,000

The system should enforce:

[
Q_{\text{allocated}} \leq Q_{\text{retired}}
]

This is a basic accounting invariant.

For partial allocations, the implementation should track the remaining quantity instead of treating an entire serial range as a single indivisible item.

A production contract could maintain:

struct CreditBalance { uint256 retiredQuantity; uint256 allocatedQuantity; } 

Before accepting an allocation:

require( balance.allocatedQuantity + quantity <= balance.retiredQuantity, "Allocation exceeds retired quantity" ); 

The application should also prevent an allocation from being assigned to conflicting beneficiaries or reporting periods where that would violate its policy.

This is where the system becomes more than a simple registry. It becomes a controlled accounting layer for environmental claims.

9. Confidentiality versus independent verification

There is an important design trade-off here.

A company may not want the public to see its exact carbon-credit purchases, supplier relationships, or internal allocations.

An auditor, however, needs sufficient evidence to validate the company's claim.

These requirements are not necessarily contradictory.

CarbonVault can separate information into different disclosure levels:

Information Default handling
Original retirement certificate Encrypted off-chain storage
Supplier identity Restricted access
Purchase price Confidential
Internal beneficiary Confidential
Credit commitment Used for integrity checks
Registration status Confidential application state
Audit evidence Shared with authorized auditors
Public application events Opaque identifiers only

A permissioned audit workflow can disclose relevant evidence to an authorized auditor without making all commercial information public.

However, confidentiality is not the same as selective cryptographic disclosure.

If an auditor needs to independently verify a particular claim, the system must provide an appropriate evidence package or verification interface. A commitment alone cannot reveal whether the underlying claim is true.

For particularly sensitive applications, consider adding:

  • Signed audit reports.
  • Selective disclosure credentials.
  • Merkle proofs for individual credit records.
  • Independent registry verification.
  • Zero-knowledge proofs for specific accounting statements.

These are potential extensions, not features automatically provided by Sapphire or ROFL.

10. Security and threat model

A carbon-credit application needs to defend against both technical attacks and accounting manipulation.

Threat Mitigation
Duplicate credit registration Deterministic nullifiers and unique registration checks
Overlapping serial ranges Per-credit nullifiers or interval-overlap detection
Fake retirement evidence Registry authentication and evidence verification
Malicious verifier ROFL authentication and restricted permissions
Replayed verification request Idempotency keys and state-transition checks
Unauthorized allocation Role-based authorization
Allocation exceeding retired quantity On-chain accounting invariants
Compromised registry API Independent evidence checks and signed records where available
Confidential data leakage Encrypted inputs, restricted outputs, and careful event design
Administrator compromise Multisignature governance and key rotation

There are also broader carbon-market risks that smart contracts cannot solve alone.

A credit can be correctly registered and retired while still being associated with a project whose environmental impact is disputed. Questions about additionality, permanence, leakage, and methodology require independent standards and assessment.

Blockchain verification should not be confused with environmental verification.

11. Testing strategy

Before deployment, test the accounting and verification invariants independently.

Contract tests

  • An unauthorized address cannot register credits.
  • A credit identifier cannot be registered twice.
  • An allocation cannot reuse an existing allocation identifier.
  • An allocation cannot exceed the available retired quantity.
  • Revoked records cannot be allocated.
  • Invalid state transitions revert.

Verifier tests

  • An invalid registry response is rejected.
  • A non-retired credit cannot be registered.
  • A mismatched serial range and quantity is rejected.
  • A request with an incorrect project identifier fails.
  • Repeated requests do not create inconsistent records.
  • A registry timeout does not result in automatic approval.

Privacy tests

  • Sensitive documents never appear in public events.
  • Private contract calls use the appropriate Sapphire client integration.
  • Registry credentials never appear in logs.
  • Unauthenticated users cannot access evidence.
  • Public identifiers do not unnecessarily reveal commercial relationships.

Integration tests

Test the entire flow:

  1. Submit a synthetic retirement record.
  2. Verify it using a mock registry adapter.
  3. Generate its nullifier.
  4. Submit the authenticated verification result.
  5. Register the credit in Sapphire.
  6. Attempt to register the same credit again.
  7. Verify that the duplicate attempt fails.
  8. Allocate part of the retired quantity.
  9. Attempt an allocation exceeding the remaining balance.

Only after these tests pass should you move toward an actual registry integration and testnet deployment.

Sapphire testing documentation:

https://docs.oasis.io/build/sapphire/develop/testing/

12. Deployment roadmap

A sensible implementation sequence is:

Phase 1: Local development

  • Implement the Solidity registry.
  • Build the nullifier library.
  • Create a mock carbon registry adapter.
  • Write duplicate-registration and allocation tests.

Phase 2: Sapphire integration

  • Deploy the contract to Sapphire Testnet.
  • Configure a Sapphire-compatible frontend.
  • Test encrypted transactions and queries.
  • Review all public events and metadata.

Phase 3: ROFL integration

  • Package the verifier as a ROFL application.
  • Configure the registry adapter.
  • Set up the required secrets and application identity.
  • Implement authenticated contract interaction.
  • Test attestation and authorization assumptions.

Phase 4: Real registry integration

  • Select a carbon registry.
  • Implement its actual evidence retrieval and verification process.
  • Validate its retirement and beneficiary semantics.
  • Document the trust model for registry data.

Phase 5: Production readiness

  • Complete independent security reviews.
  • Implement overlap detection and partial allocations.
  • Add recovery, revocation, and dispute handling.
  • Define auditor access and evidence retention.
  • Review legal and carbon-accounting requirements.

Use synthetic records during development. Never place real commercial documents or personal information into test environments.

Conclusion

Carbon markets need more than digital records. They need reliable evidence, clear accounting, and ways to establish trust between organizations that may not want to reveal every commercial detail.

CarbonVault demonstrates how Oasis Sapphire and ROFL could contribute to that problem.

Sapphire provides a foundation for confidential application state and authorization. ROFL can support protected off-chain verification. External registries remain responsible for authoritative credit issuance and retirement records.

Together, these components create an architecture in which organizations can manage carbon-credit allocations, detect duplicate registrations, and share appropriate audit evidence without publishing every underlying business relationship.

The broader opportunity extends beyond carbon credits.

The same design principles could be applied to renewable energy certificates, environmental attribute tracking, supply-chain sustainability claims, and other systems where assets must be verified and accounted for without exposing all associated commercial information.

The interesting question is no longer simply how to put environmental assets on a blockchain. It is how to make environmental claims verifiable while keeping sensitive business information confidential.

Further reading

Disclaimer: CarbonVault is a proposed educational architecture, not an existing deployed product or audited implementation. The code examples are illustrative and require additional engineering, registry-specific integration, testing, and independent security review before production use.