CIPHERA × CITREA/BURN SUBSTITUTOR/FUNDS PERSPECTIVE

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.

Branchburn-substitutor
ContractRollupV1.sol
Settlement L2Citrea
Off-chain legLightning
The one thing to hold in your head — Ciphera is a contract on Citrea that holds a pool of cBTC. Deposits add to the pool in exchange for a private note. Withdrawals draw from it. Substitutors are just liquidity providers who front the payout and get reimbursed from the same pool later. Everything else is mechanics.
Read the five flows as five ways of answering the question “where does the cBTC go?” Four flows are atomic and sound. The fifth — withdraw to Lightning — is the only one that crosses chains, so it is the only one with live bugs. That is where every concern lives.
01
The ledgers you have to track.
8 actors

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.

CITREA EVM

User · private note

A commitment in the Ciphera circuit. Only the holder can spend it.

CIPHERA / ZK

User · Lightning

BTC the user receives on the Lightning Network.

LIGHTNING

Ciphera reserve

cBTC the Rollup contract actually holds. Conceptually tracks currentTvl.

ROLLUP CONTRACT

Sub · cBTC

Substitutor's Citrea-side working capital for fronting payouts.

CITREA EVM

Sub · Lightning

Substitutor's LN channel balance. Pays user invoices on request.

LIGHTNING

feeSink

Treasury address that receives burn fees skimmed by the contract.

CITREA EVM

MW / Escrow

Off-chain middleware; holds BTC and cBTC reserves, coordinates swaps.

OFF-CHAIN SERVICE
Color channels — cBTC on Citrea BTC on Lightning private note (ZK commitment) danger / loss path
02
The whole system in one picture.
hub view

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.

CIPHERA CONTRACT on Citrea — holds the cBTC pool cBTC pool accounting: currentTvl DEPOSITS F1 · cBTC transfer user already holds cBTC → safeTransferFrom into pool F2 · Lightning in user pays LN invoice MW tops up pool (plain transfer) MW calls mintClaimed() ⚠ trust-based, no on-chain check private note → user WITHDRAWALS F3 · direct burn slow · pool pays B on proof ⚠ silent failure on revert F4 · on-chain sub fast · Sub fronts, pool refunds ✓ atomic, single Citrea tx F5 · withdraw to LN Sub pays LN, pool refunds Sub ⚠ not atomic — bugs live here concerns #1 #3 #4 user's private note consumed (ZK) feeSink skimmed burn fees
03
Flow 1 · Deposit, the easy case.
atomic · safe

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.

01
mint() · cBTC → private note
Atomic

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 CIPHERA PROVER STEP 01 approve(ciphera, v) STEP 02 safeTransferFrom · v cBTC STEP 03 state · mints[hash] = (v, kind, spent:false) currentTvl += v STEP 04 verifyRollup · mints[hash].spent = true private note · v
00 / 04
Net across the full cycle
User · cBTC− vpublic balance spent
Ciphera reserve+ vpool grows by v
User · private note+ vnew commitment created
currentTvl+ vmatches the reserve delta
04
Flow 2 · Depositing from Lightning.
off-chain bridge · trust

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.

02
mintClaimed() · BTC → private note
Trust-based

Two legs happen separately. The contract sees only one of them.

USER · LN MW CIPHERA PROVER 01 pays invoice · Y BTC 02 plain token.transfer · Y cBTC (top-up — no on-chain attribution) 03 escrow → mintClaimed(hash, Y, kind) currentTvl += Y mints[hash] recorded 04 verifyRollup · spent = true 05 private note · Y
00 / 05
MW can call mintClaimed without topping up Concern #6
Nothing in the contract forces the reserve top-up and the mintClaimed call to be linked. A compromised escrow manager can mint notes up to globalTvlCap backed by nothing. This path is currently untested.
Net across the full cycle · happy path
User · LN− Y BTCpaid the invoice
MW reservesnet 0 (+ Y BTC, − Y cBTC)swaps one asset for another
Ciphera reserve+ Y cBTCpool grows by Y
User · private note+ Ynew commitment
05
Flow 3 · Withdraw direct to a Citrea address.
slow · silent fail risk

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.

03
verifyBurn · kind=3 · direct burn
Silent fail risk

Flip the branch picker to see what happens when the transfer to B reverts.

USER (NOTE) PROVER CIPHERA B feeSink 01 burn UTXO (kind=3, addr=B, v) 02 verifyRollup(batch) 03 transfer · v − fee 04 fee · fee 05 currentTvl −= v (or −= payout if fee stuck) 06 commitment marked spent · user note zeroed
00 / 06
Silent failure on transfer(B, payout) Concern #2
If B 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.
Net · happy path
User · private note− vcommitment spent
B · cBTC+ (v − fee)receives the payout
feeSink+ feeskim to treasury
Ciphera reserve− vpool shrinks by v
06
Flow 4 · Fast withdraw via on-chain substitutor.
atomic · safe

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.

04
substituteBurnTo · fronted payout · on-chain
Atomic · zero-profit

The substitutor's on-chain P&L is exactly zero. Their only incentive is off-chain (operator of the service, LSP spread, etc.).

SUB CIPHERA B PROVER feeSink — PHASE 1 · now (one tx) — 01 Sub pre-pays · v − fee 02 executeBurn · v − fee → B 03 substitutedBurns[key] = Sub overwrite-guarded ✓ — PHASE 2 · later (rollup proof lands) — 04 verifyRollup · reads substitutedBurns[key] 05 refund Sub · v − fee 06 fee · fee currentTvl −= v
00 / 06
Net across the full cycle
User · private note− vcommitment spent
B · cBTC+ (v − fee)receives payout immediately
Sub · cBTC0fronted and refunded — zero on-chain profit
feeSink+ feeskim to treasury
Ciphera reserve− vpool shrinks by v
07
Flow 5 · Withdraw to Lightning. Where the bugs live.
NOT ATOMIC

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.

05
NoSub (kind=4) · cross-chain substitution
Cross-chain · 3 risk branches

Start with the happy path, then switch branches to see exactly how each concern plays out through the ledgers.

USER MW SUB (cBTC) SUB (LN) CLAIMER CIPHERA PROVER feeSink — PHASE 1 · user burns, citrea side — 01 quote · X cBTC → Y BTC 02 burn UTXO · kind=4 · addr=Sub · X — PHASE 2 · sub does the cross-chain dance — 03 Sub polls MW · matches swap 04 LN payment · Y BTC 05 burnClaimed(Sub, key) substitutedBurns[key] = Sub — PHASE 3 · rollup proof lands — 06 verifyRollup · reads map 07 refund Sub · X − fee fee
00 / 07
Orphan NoSub burn Concern #3
If Sub gives up silently (no swap match, MW 5xx, empty 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 overwrite Concern #1
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.
MW-crafted EscrowData · narrower residual surface Concern #4
Commit 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.
Net · happy path
User · private note− Xcommitment spent
User · LN+ Y BTCreceived via Sub
Sub · cBTC+ (X − fee)refunded by contract
Sub · LN− Y BTCpaid user invoice
feeSink+ feeskim to treasury
Ciphera reserve− Xpool shrinks by X
08
Where money can get stuck or lost.
audit surface

All concerns aggregated into a single table. Columns tell you which flow has the risk, who eats the loss, and how much per incident.

ConcernFlowScenarioWho losesHow much
#2Flow 3Direct burn transfer reverts (B blacklisted or reverts)Userfull v · per incident
#3Flow 5Orphan NoSub — Sub never completes; rollup still pays SubUserfull X · per incident
#1Flow 5burnClaimed overwriteLegit substitutorY BTC · per incident
#4Flow 5MW crafts adversarial EscrowData — offerer/amount pinned (mitigated in 873d07a), inner fields & target contract not validatedSubstitutor · parameter-dependentnarrow · Atomiq-semantics bound
#6Flow 2mintClaimed called without top-up / compromised escrowFuture burnersup to globalTvlCap
#7infraIn-flight substitution at V2 init(accounting drift, not loss)clamped · operational
by designFlow 3/4/5feeSink unset or blacklistedfeeSink / treasuryfee · up to MAX_BURN_FEE=3000 sats
09
The invariant that would have caught all of this.
assertion

If you want one line that separates "pool is honest" from "something has silently broken" — this is it.

contract-money invariant
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.