Project: Build an Airtime Proof-of-Reserve Cycle
A complete, runnable project implementing the core proof-of-reserve cycle used throughout Comet Engine: mint a token 1:1 against a real-world deposit, then burn it the moment that value is actually delivered — so on-chain supply never drifts from the real float. See the Tokenization API for the endpoint reference.
What you'll build
A cycle.js script that tokenizes a KES 50 airtime deposit into IMC (Celo) or SIMC (Stellar), then burns it back the moment delivery is confirmed — leaving the journal balanced and on-chain supply exactly matching the real airtime float at every point in between, not just at the start and end. Pick a chain above — every step below follows that choice.
Prerequisites
- Node.js 18+ (for global
fetch) - A running comet-engine (
cd comet-engine && go run ./cmd/engine) with a tenant API key/secret — see Authentication
Project setup
mkdir airtime-cycle && cd airtime-cycle && npm init -y && npm install dotenv.env (same for both chains — only chain in the script body changes):
COMET_BASE_URL=http://localhost:8100
COMET_API_KEY=your-tenant-api-key
COMET_API_SECRET=your-tenant-api-secretCreate cycle.js with this shared helper at the top — every step below adds to the same file:
import 'dotenv/config';
const base = process.env.COMET_BASE_URL;
const headers = {
'Content-Type': 'application/json',
'X-API-Key': process.env.COMET_API_KEY,
'X-API-Secret': process.env.COMET_API_SECRET,
};
async function comet(method, path, body) {
const res = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json();
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(data)}`);
return data;
}Step 1: Tokenize the deposit
The user has already paid KES via M-Pesa (handled by app-core-backend, outside this project). Tokenizing mints the equivalent airtime token to their wallet:
curl -X POST "http://localhost:8100/api/v1/tokenize/airtime" \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-API-Secret: YOUR_API_SECRET" \
-d '{
"userId": 24,
"amountBase": "50000000",
"externalId": "airtime-tx-001",
"chain": "celo"
}'{
"status": "success",
"asset": "IMC",
"txHash": "0x..."
}Step 2: Deliver the airtime
Handled by your own provider integration (e.g. ImpalaPay via app-core-backend) — outside comet-engine's scope, and outside this project. The next step assumes delivery is confirmed.
Step 3: Burn to remove the on-chain mirror
curl -X POST "http://localhost:8100/api/v1/assets/imc/burn-by-holder" \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-API-Secret: YOUR_API_SECRET" \
-d '{
"userId": 24,
"amountBase": 50000000,
"externalId": "airtime-tx-001"
}'{
"txHash": "0x...",
"symbol": "IMC",
"amount": 50
}Journal entries:
| Entry | Debit | Credit | Amount |
|---|---|---|---|
| Tokenize | airtime_inventory::comet | chain::celo::imc | +50 IMC |
| Burn | chain::celo::imc | airtime_inventory::comet | -50 IMC |
Net effect either way: journal balanced, on-chain supply unchanged after the full cycle.
Idempotency
Use the same externalId for the tokenize and burn calls of the same transaction. This prevents double-minting or double-burning if a request is retried.
Step 4: Run the whole cycle as one script
Add main() to cycle.js — only the asset symbol and endpoint path change per chain, matching the steps above:
async function main() {
const externalId = `airtime-tx-${Date.now()}`;
// Step 1: mint 50 IMC against a KES deposit already handled upstream
const minted = await comet('POST', '/api/v1/tokenize/airtime', {
userId: 24, amountBase: '50000000', externalId, chain: 'celo',
});
console.log('minted:', minted.txHash);
// Step 2 (in a real app): deliver the airtime via your provider here
// Step 3: burn once delivery is confirmed
const burned = await comet('POST', '/api/v1/assets/imc/burn-by-holder', {
userId: 24, amountBase: 50000000, externalId,
});
console.log('burned:', burned.txHash);
console.log('cycle complete — on-chain supply back to where it started');
}
main().catch((e) => { console.error(e); process.exit(1); });node cycle.jsWhat you built
- A working mint → burn cycle backed by a real deposit, not a paper balance.
- A journal that always nets to zero once the cycle completes — on-chain supply provably tracking the real float.
- The same idempotency guarantee production traffic needs: retried calls with the same
externalIdnever double-mint or double-burn.
Next steps: swap the hardcoded userId/amounts for your own deposit-confirmation webhook, or see Custom Loyalty Token if you want this same mint/burn shape for a token that isn't airtime-backed.