Follow the money,
not the rollup jargon.
A walk-through of every fund flow in the burn-substitutor branch — five flows, drawn at the level where you can actually tell who pays whom, in which asset, on which chain. Each diagram is scrubbable: step forward and watch one transfer at a time.
Eight balance sheets move in this story. Print them in your head before reading the flows; every diagram refers back to this list.
User · cBTC
Public ERC20 balance the user holds directly on Citrea.
User · private note
A commitment in the Ciphera circuit. Only the holder can spend it.
User · Lightning
BTC the user receives on the Lightning Network.
Ciphera reserve
cBTC the Rollup contract actually holds. Conceptually tracks currentTvl.
Sub · cBTC
Substitutor's Citrea-side working capital for fronting payouts.
Sub · Lightning
Substitutor's LN channel balance. Pays user invoices on request.
feeSink
Treasury address that receives burn fees skimmed by the contract.
MW / Escrow
Off-chain middleware; holds BTC and cBTC reserves, coordinates swaps.
Ciphera holds a pool. Mints add to it; burns draw from it. The five flows are five doors in and out of this single pool.
User already holds cBTC on Citrea. They send it into the Ciphera contract. In exchange, minutes later, the ZK circuit hands them a private note of equal value.
All on Citrea, all in cBTC. The only delay is the rollup proving cadence, which is what turns the commitment into a spendable note.
| User · cBTC | − v | public balance spent |
| Ciphera reserve | + v | pool grows by v |
| User · private note | + v | new commitment created |
| currentTvl | + v | matches the reserve delta |
User has BTC on Lightning. An off-chain middleware absorbs the LN payment, tops up Ciphera's cBTC reserve out of its own inventory, then tells the contract "credit this user". The contract has no way to verify the top-up actually happened — it trusts the escrow manager.
Two legs happen separately. The contract sees only one of them.
mintClaimed without topping up Concern #6mintClaimed call to be linked. A compromised escrow manager can mint notes up to globalTvlCap backed by nothing. This path is currently untested.| User · LN | − Y BTC | paid the invoice |
| MW reserves | net 0 (+ Y BTC, − Y cBTC) | swaps one asset for another |
| Ciphera reserve | + Y cBTC | pool grows by Y |
| User · private note | + Y | new commitment |
User spends a private note; the Ciphera contract eventually pays EVM address B when the proof lands. Fast enough for most cases, but no substitutor is involved — so the user waits out the proving interval.
Flip the branch picker to see what happens when the transfer to B reverts.
transfer(B, payout) Concern #2B is blacklisted or its receive reverts, the cursor advances (i += 5) so the prover treats the burn as consumed — but no fee routing runs, no currentTvl update happens, and the tokens remain stranded in the contract. There is no recovery map; only a Burned(success=false) event marks the loss.| User · private note | − v | commitment spent |
| B · cBTC | + (v − fee) | receives the payout |
| feeSink | + fee | skim to treasury |
| Ciphera reserve | − v | pool shrinks by v |
Still withdrawing to a Citrea EVM address, but now a substitutor fronts the payout from their own cBTC. The contract records the claim in the same transaction, then refunds the substitutor later when the proof lands. Two legs, but each leg is atomic.
The substitutor's on-chain P&L is exactly zero. Their only incentive is off-chain (operator of the service, LSP spread, etc.).
| User · private note | − v | commitment spent |
| B · cBTC | + (v − fee) | receives payout immediately |
| Sub · cBTC | 0 | fronted and refunded — zero on-chain profit |
| feeSink | + fee | skim to treasury |
| Ciphera reserve | − v | pool shrinks by v |
This is the only flow that crosses chains — a Citrea-side burn and a Lightning-side payment. Ciphera only ever sees the Citrea half. Every concern in the risk matrix lives in this flow.
Start with the happy path, then switch branches to see exactly how each concern plays out through the ledgers.
commit_txs), burnClaimed never fires, the substitutedBurns map stays zero, and verifyBurn takes the direct-burn branch — paying burn_addr = Sub anyway. User is out X on both sides of the ledger.burnClaimed sets substitutedBurns[key] without a zero-check or overwrite guard — asymmetric with the on-chain substituteBurnTo which does guard. A second call silently replaces the claim; the rollup refunds whoever wrote last.873d07a added EscrowData::from_transaction_calldata() plus two hard checks — escrow.offerer == substitutor_address and escrow.amount == burn_value. That blocks the blanket hot-wallet drain I initially claimed. What remains is narrower: commit_tx.to has no allowlist, and the inner escrow fields (claim_handler, refund_handler, token, claimer) aren't validated. A compromised MW could still craft an escrow with hostile handlers — severity is Atomiq-semantics-bound and depends on Sub's token allowance scope.| User · private note | − X | commitment spent |
| User · LN | + Y BTC | received via Sub |
| Sub · cBTC | + (X − fee) | refunded by contract |
| Sub · LN | − Y BTC | paid user invoice |
| feeSink | + fee | skim to treasury |
| Ciphera reserve | − X | pool shrinks by X |
All concerns aggregated into a single table. Columns tell you which flow has the risk, who eats the loss, and how much per incident.
| Concern | Flow | Scenario | Who loses | How much |
|---|---|---|---|---|
| #2 | Flow 3 | Direct burn transfer reverts (B blacklisted or reverts) | User | full v · per incident |
| #3 | Flow 5 | Orphan NoSub — Sub never completes; rollup still pays Sub | User | full X · per incident |
| #1 | Flow 5 | burnClaimed overwrite | Legit substitutor | Y BTC · per incident |
| #4 | Flow 5 | MW crafts adversarial EscrowData — offerer/amount pinned (mitigated in 873d07a), inner fields & target contract not validated | Substitutor · parameter-dependent | narrow · Atomiq-semantics bound |
| #6 | Flow 2 | mintClaimed called without top-up / compromised escrow | Future burners | up to globalTvlCap |
| #7 | infra | In-flight substitution at V2 init | (accounting drift, not loss) | clamped · operational |
| by design | Flow 3/4/5 | feeSink unset or blacklisted | feeSink / treasury | fee · up to MAX_BURN_FEE=3000 sats |
If you want one line that separates "pool is honest" from "something has silently broken" — this is it.
balanceOf(cBTC, rollup) == currentTvl + sum(stuck fees from FeeStuck events) // intentional + sum(in-flight on-chain substitutions) // intentional, V2 clamps + sum(silently-failed burns from Burned(success=false)) // should be ZERO — Concern #2 // If the third term is non-zero, a direct burn failed silently and // the tokens are stranded with no on-chain claim. Monitor this number // continuously; page on any delta > 0.