compāgēs: a joining together; a framework.
Compages is a centralized, operator-run bridge into the Sequentia network from three chains:
- Ethereum: lock ether or any ERC-20 in a vault contract, receive a
matching Sequentia asset (
SYMBOL.e); sending it back releases the original funds. - Bitcoin: bitcoin needs no bridge to be used on Sequentia. Every
Sequentia wallet holds and spends native bitcoin directly, at the same
tb1...address it uses for Sequentia assets. For the few uses that need bitcoin on the Sequentia chain itself (confidential transactions, and anything that needs a covenant, such as a resting limit order), BTC sent to a bridge address is wrapped 1:1 as SBTC (custody, issuance and redemption are performed by the sbtc-bridge service; Compages is the public front for it). - Solana: send SOL or any SPL token to a bridge address and receive the
matching Sequentia asset (SOL.s, or the token under its own
.sticker); sending it back releases the original.
It is a proof of concept running on the Sepolia testnet, Bitcoin testnet4 and the Solana devnet against the Sequentia public testnet, live at:
Everything here is testnet software. There is no mainnet deployment, and the tokens involved have no value.
This is a custodial bridge. Deposited funds are held by the vault contract and can only be moved by the bridge's keys; minting on Sequentia and releases on Ethereum are actions the operator performs. If the operator disappears or misbehaves, bridged funds are lost. Users trust the operator. This is a demonstration of the bridging mechanics, not a trust-minimized design.
Within that assumption, the design removes every failure mode it can:
- Releases and refunds are keyed by deterministic ids and replay-guarded on
chain (
processedRedemptions), so nothing can be paid twice. - The vault splits its authority across three keys (see "The vault contract"): a cold owner, a hot operator whose immediate payouts are rate-limited per token, and a guardian that can only stop things. A payout over the limit waits in a timelocked queue where it can be cancelled, so a leaked operator key cannot empty the vault at once.
- A payout the recipient cannot accept (a contract that rejects ether, a blocklisted address) never blocks a redemption: the vault records the amount as owed and the recipient claims it to any address it chooses.
- Minting on Sequentia is limited the same way. Each asset's reissuance token sits in a mint limiter covenant (see "The mint limiter"): the daemon's hot key mints at most an allowance per window of blocks, and a cold key that never reaches the bridge host can brake it and recover the token. A leaked operator key cannot mint an unbounded supply.
- Every deposit of the same ERC-20 mints the same Sequentia asset; the mapping from token contract to Sequentia asset id is created exactly once (on the first deposit for an ordinary token, in the issuance ceremony for a unified stablecoin, see below), so no duplicate assets can exist.
- Redeemed Sequentia amounts are destroyed, keeping the circulating bridged supply equal to the locked Ethereum funds.
- Deposits that cannot be delivered (invalid Sequentia address, amount not representable) are refunded automatically on Ethereum. The Solana leg removes the failure mode instead: the Sequentia destination is validated before a deposit address is ever handed out.
- Irreversible releases (on Ethereum and on Solana alike) are gated on Bitcoin-anchor finality of the Sequentia burn, not on a Sequentia block count (see below).
- Solana transfers have no vault contract to replay-guard them, so the daemon uses the chain itself: a Solana transaction's id is its fee payer's signature, known before broadcast, and every outbound transfer's signature is persisted before sending. After a crash the recorded signature answers, on chain, whether the transfer landed or can never land.
For the stablecoins declared under unified in the config (live: USDC.e
and EURC.e), there is exactly one Sequentia asset per coin, whichever
chain a deposit came from: a USDC deposit from Sepolia and one from the
Solana devnet both mint into the same USDC.e. The bridge only unifies tokens
the operator has explicitly declared to be the same money, never tokens that
merely share a symbol.
- The asset is issued once, in a ceremony at daemon start, before any deposit, with zero supply and exactly one reissuance token, so backing is exact from the first atom and the mint authority is a single object. The daemon refuses to run if the ceremony fails.
- It is issued at the issuer's own precision (6 for USDC and EURC), not the 8 decimals ordinary bridged assets use; amounts convert at that precision.
- It is issued as a node-level supervised asset with pause
(
supervision: {enabled, pause}): the holder of the operational key can freeze individual holdings, or pause the asset, by consensus rule. This is the node's supervised-asset feature, not OpenAMP. It is permanent: both supervision keys are committed in the asset id. By default the daemon derives them from the bridge wallet, which makes that wallet's backup the freeze authority; a production issuer pins its own public keys instead. unifiedIssuerPubkeyis hashed into the asset id and later authorizes handing the asset's registry identity to the real issuer, which is why the asset is built this way: so the issuer can adopt it in place.
The specification is
docs/bridged-usdc-standard.md.
| Piece | What it does |
|---|---|
| Ethereum → Sequentia (lock, then mint) | ETH and ERC-20 deposits, first-bridge issuance, duplicate-free reissuance, automatic refunds |
| Sequentia → Ethereum (return, then release) | Releases the locked funds against a returned bridged asset; live redemptions wait for 100 Bitcoin-anchor confirmations first (see "Finality") |
| Vault contract | CompagesVault on Sepolia at 0x7B702D6A2E2351F0c4E549642e65AbABC0324384, which takes deposits. Two earlier vaults, 0xd72AF53b… and 0x15b3c97e…, accept no new deposits; the daemon still watches all three |
| Unified stablecoins | USDC.e and EURC.e, precision 6, fed from Sepolia and the Solana devnet, node-level supervised (see "Unified stablecoins") |
| Bitcoin ↔ SBTC (wrap, unwrap) | Address-based, proxied to the sbtc-bridge custody service (/api/btc/*); only for uses that need bitcoin on the Sequentia chain, since Sequentia wallets hold native bitcoin directly |
| Solana ↔ Sequentia (wrap, sweep, unwrap; SOL and any SPL token) | Implemented natively in the daemon (daemon/lib/sol.js, no extra dependency) |
| Asset Registry integration | Bridged assets are registered with origin-suffixed tickers (SYMBOL.e Ethereum, SOL.s Solana), bound on-chain via the issuance contract hash |
| Web front-end | Served by the daemon itself at https://sequentiatestnet.com/bridge/: web/index.html, web/app.js, web/abi.js (the hand-written encoding of the few contract calls the page makes) and web/qr.js (the page's own QR encoder) |
Every leg is exercised end to end by e2e/run-e2e.sh.
Chain ids, RPC endpoints, the vault address and confirmation depths are all configuration, and asset mappings are keyed per chain id, so nothing in the code pins it to a particular network. It has only ever run on testnets.
- Open https://sequentiatestnet.com/bridge/ and connect an Ethereum wallet (e.g. MetaMask) on Sepolia.
- Pick an asset: ETH, one of the already-bridged tokens, or paste any ERC-20 contract address. The page tells you whether this would be the first bridge of that token (your deposit issues a brand-new Sequentia asset) or whether it mints more of an existing asset.
- Enter the amount and your Sequentia address. The default
tb1...address from any Sequentia wallet works. A confidential (blinded)tsqb1...address works too and hides the amount received on chain, except for supervised assets (USDC.e, EURC.e): consensus never lets one sit in a blinded output, so the bridge delivers it to the same address's transparenttb1...form, the same wallet with the amount visible. The page checks the address with the bridge's node as you type and keeps the deposit button disabled while it is invalid. When a Sequentia wallet is installed in the browser, "Use my Sequentia wallet" fills it in. A preview shows the exact amount and ticker you will receive (SYMBOL.e) and the expected wait before you commit. - Confirm the deposit. For an ERC-20 the page first requests an
approve, and resets an existing non-zero allowance to zero first for tokens that require it. Once Ethereum finalizes the block holding your deposit (about 15 minutes), the daemon mints on Sequentia and sends the asset to your address. The page tracks each stage with a progress bar and the time left. It remembers the last deposit and resumes tracking when you come back; the "Track a deposit" box follows any deposit by its Ethereum transaction hash. If your wallet speeds up or replaces the transaction, the page says so and asks for the new hash.
- On the "Sequentia → Ethereum" tab, enter the Ethereum address that should receive the released funds and click "Get my redemption address". The bridge returns the Sequentia address bound to that Ethereum address; each Ethereum address has one, and asking again returns the same one. With an Ethereum wallet connected, the page shows its redemption address for the chain chosen under "Receive on", and its redemptions, without asking.
- Send the bridged asset to that address from any Sequentia wallet. No special transaction format is needed.
- Once the transfer is final under Bitcoin anchoring (100 Bitcoin-anchor confirmations on the live deployment, roughly 17 hours at the 10-minute block target), the vault releases the locked ether or tokens to your Ethereum address, and the returned Sequentia amount is destroyed. The page shows each redemption's progress toward finality with the time left, remembers the address for your next visit, and "Look up a redemption address" finds one again by the redemption address or by the Ethereum address it pays.
"Receive on" chooses where USDC.e is paid out: Sepolia, or another EVM chain Circle's CCTP reaches from the vault. Every other asset is always paid on Sepolia. A redemption address is bound to the address and the chain together, and "Receive on" always shows the chain of the redemption address on screen. For a payout on another EVM chain the vault burns the USDC on Sepolia; once Circle has attested the burn, the page offers "Claim on ", which switches your wallet to that chain and sends the attested message there. Anyone may send it, and it mints only to the recipient the burn names; once it is mined the page shows "Claimed" with the transaction. To receive USDC.e on Solana, use the Solana leg's unwrap address instead.
A payout larger than the vault's rate limit waits in the vault's queue, and the page shows when it goes out. The bridge's guardian can stop a queued payout; the page then says so, and the operator decides what happens next. When the receiving address refuses a payout (a contract that rejects plain ether, for example), the vault holds it for that address, and the page offers "Claim": connect that account on Sepolia, choose where the funds should go, and the vault pays them there. Refunds of undeliverable deposits behave the same way.
Choose "USDC from another chain" in the "Bridge from" selector to bridge USDC from a chain other than Sepolia over Circle's CCTP. Pick the chain, enter the amount and your Sequentia address, and confirm. The page switches your wallet to that chain (adding it when the wallet does not know it), asks you to let Circle's TokenMessenger spend the USDC, and burns it with the bridge's vault named as the only party allowed to complete the transfer. The page reports the burn to the bridge as soon as your wallet returns its hash, so it completes even if you close the page. Circle attests a burn once its chain finalizes it, typically 15 to 30 minutes on these testnets; the bridge then relays it to the vault, and the deposit mints USDC.e once Sepolia finalizes the relay, about 15 more minutes. USDC.e is the same asset as USDC bridged from Sepolia or Solana. The page follows each stage and remembers the last burn. A burn made elsewhere, in another tab or on another device, is picked up by entering its chain and transaction hash in the "Track a burn" box.
Bitcoin needs no bridge to be used on Sequentia: every Sequentia wallet holds
and spends native bitcoin directly, at the same tb1... address it uses for
Sequentia assets. SBTC, bitcoin pegged 1:1 on Sequentia, is only needed
for confidential (blinded) transactions and for anything that needs a
covenant, such as a limit order that rests on chain until it is filled. The
page says this before it shows the Bitcoin forms.
Both legs are address-based; no wallet extension is involved. Pick the chain in the "Bridge from" selector:
- Wrap: enter the Sequentia address that should receive the bridged
asset; the bridge returns a deposit address on the origin chain. Send BTC
(testnet4), or SOL or any SPL token (devnet), to it from any wallet.
Once a BTC deposit has one confirmation and Sequentia has anchored the
Bitcoin block holding it, you receive SBTC 1:1 (a Bitcoin reorg that undid
the deposit would then undo the credit too); a Solana deposit is
minted once it is finalized and picked up by the bridge, usually under a
minute: SOL as SOL.s, a token under its own origin-suffixed ticker, with
the first deposit issuing the asset and later deposits by anyone minting
more of the same one, exactly like the Ethereum leg's ERC-20s. The page
checks the Sequentia address before it requests a deposit address, and
shows the deposit address with a QR code and a payment link
(
bitcoin:<address>, orsolana:<address>withspl-token=<mint>when you pick a token). - Unwrap: enter the Bitcoin or Solana address that should receive the
released funds; the bridge returns a Sequentia address. Send SBTC or SOL.s
to it from any wallet, and once the burn is final under Bitcoin anchoring
the original BTC or SOL is released. A Sequentia wallet's
tb1...address also receives bitcoin, so "Use my Sequentia wallet" can fill the Bitcoin destination too.
The page lists every transfer to a wrap or unwrap address with its status: confirmations so far with the time left, then crediting or releasing, then the transaction that paid you. It remembers the last address it gave you on each leg and shows it again when you come back, and the "Track" box on the Bitcoin leg looks up any Bitcoin deposit address or SBTC return address. A banner at the top names any asset whose minting the operator has paused.
SOL amounts should be at least 0.001 in both directions (below Solana's rent-exempt minimum a lamport transfer cannot create the destination account; smaller SOL.s returns are parked for the operator). Token amounts have no such floor: the treasury funds the recipient's associated token account on release. An ordinary bridged asset carries 8 decimal places, so decimals beyond 8 are dropped when minting (SOL has 9; most SPL mints have 6 or 9); a unified stablecoin converts at its own precision instead.
Only assets that were bridged in can be redeemed; Compages never mints Ethereum-side or Solana-side representations of Sequentia-native assets, and an asset returned to the wrong leg's redemption address is parked for the operator, never released on the wrong chain.
- The user calls
depositEther(seqAddress)ordepositToken(token, amount, seqAddress)on theCompagesVaultcontract. - The daemon (
compagesd) picks the deposit up from theDepositedevent once Ethereum has finalized its block (ethFinality), and checks that every deposit number the vault has counted has a record, so a log an RPC failed to return is found rather than lost. - First deposit of a token: the daemon issues a new reissuable Sequentia
asset carrying the token's symbol, name and decimals, and records the
mapping. Every later deposit of that token, by anyone, reissues the same
asset. A token declared under
unifiedis routed to the asset issued in the start-up ceremony instead, which already exists before any deposit. - The minted amount is sent to the user's Sequentia address, or, for a deposit to a leaf intent's address, paid into an Arca receive request (below).
Amounts convert 1:1 with decimal normalization at the asset's precision: an
ordinary bridged asset has 8 decimal places, so a token with more than 8
decimals bridges at a granularity of 10^(d-8) base units (the web app
limits inputs accordingly, and the daemon refunds a deposit too small to
represent); a unified stablecoin converts at its issuer's precision (6 for
USDC and EURC).
A deposit can arrive as a leaf of an Arca operator instead of an on-chain coin: the depositor's Arca wallet hands out a receive request, and the bridge pays the minted amount into it out of round. This route is reachable through the API and the wallets' developer mode; the web page does not offer it.
- The depositor asks the bridge to bind the request to an address
(
POST /api/leaf/intent, with the request and the token). A request is refused before anything is locked when it is for another operator or chain, names an amount, asks for another asset, is for a token not bridged yet or an asset the operator does not serve, or lapses withinleafWallet.intentLapseSeconds(two days by default). For a supervised asset it is also refused when it names no landing (see below). The answer is a fresh Sequentia address of the bridge's wallet and the fallback the deposit takes if the request cannot be paid when it lands: an on-chain delivery to thefallbackAddressthe depositor named, or else a refund on Ethereum. - The depositor deposits with that address as the Sequentia address. The vault's event carries the short address, and the daemon maps it to the request.
- When the deposit lands, the request is checked again: the bridge's leaf
wallet still on its operator and chain, the asset served (and, for a
supervised asset, the request naming its landing), the request
unused and not lapsing within
leafWallet.landingLapseSeconds(six hours by default), the amount above the operator's smallest leaf. A request that fails takes the stated fallback, with the reason on the record. A request is paid once, through whichever of its intents a deposit reaches first; a later deposit takes its own intent's fallback. - The mint limiter mints exactly what the deposit credits, straight into
the bridge's own leaf wallet (an
arcawallet on the operator, driven through its command line), which boards it, paying the board's fee inleafWallet.feeAsset(the daemon keeps those fee coins topped up from its own stock ofseqFeeAsset). - Once the board is final (its block certified and its anchor buried two
Bitcoin blocks), the wallet pays the request out of round from that coin
alone. The leaf holds the deposit's credit less the transfer's margins,
the few atoms every out-of-round payment leaves in the asset moved for
the fees of its pre-signed transactions; the record states both
(
leaf.paid).
The leaf is minted per deposit, never ahead of one, so circulating supply
never exceeds escrow and the reserves read from the two chains alone stay
the whole proof. One asset's leaf deposits pass through the wallet one at a
time, from board to payment, so each is paid from its own coin. A deposit
whose request lapses, or is refused by the operator, after its board is
leaf_held: its leaf stays in the bridge's wallet, counted as owed to the
deposit, until an operator pays a new request of the depositor's
(admin.js leaf-pay). The runbook is docs/leaf-deposits.md.
A supervised asset (one its issuer can freeze, as the unified
stablecoins are) is held in an Arca operator's tree only in landing-1
leaves: each leaf names a landing key, a P2WPKH key of its holder's own,
where the leaf's exit pays and where the issuer's freeze follows it. The
operator says which assets it serves so (info's assets[].supervised,
with its landing_allowance). Such a deposit is paid into a landing-1
leaf for the depositor's leaf key and the depositor's landing key, which
the receive request names (landing, the hex of a 20-byte P2WPKH program;
every current Arca wallet's request carries it). A request without one is
refused at the intent, before anything is locked: the depositor asks the
wallet for a new request. Between the board and the payment the bridge's
own leaf wallet holds the deposit in a landing-1 leaf at the leaf
wallet's own landing key, so the leaf wallet must be an arca new enough
to have one (its info shows landing_address); until it is, intents for
a supervised asset are refused and the start-up log says why. A freeze of
the bridge's landing by the issuer stops the payment: the deposit goes
leaf_held until the issuer lifts it (the runbook says what to do).
Supervision changes nothing in the reserves: supply, escrow and the reconciliation are counted for a supervised asset exactly as for any other, and its leaves are minted per deposit like every leaf.
- The user asks the bridge for a redemption address bound to their Ethereum
address (
POST /api/redeem; the front-end does it in one click). - They send the bridged asset to that address from any Sequentia wallet.
- Once the transfer is final under Bitcoin anchoring, the daemon calls
release()on the vault to pay the locked ether or tokens to the bound Ethereum address, then destroys the returned Sequentia amount.
The Solana leg reuses the redemption-intent idea in both directions. A wrap
intent binds a fresh operator-derived deposit address (HMAC of a master seed
and an index, so every address is recoverable from the seed) to a
pre-validated Sequentia destination; the daemon watches it at finalized
commitment and mints whatever arrives through the same issue-or-reissue
machinery as the Ethereum leg: native SOL from the address's own signature
stream, and any SPL token from the streams of the token accounts the address
owns (token transfers to an existing token account do not reference the
owner, so each token account is scanned with its own cursor). Deposits are
swept into the operator treasury, which pays every fee and the rent of its
own associated token accounts, so swept amounts arrive whole. Token identity
is the mint address; decimals come from the mint account, and the name and
symbol from the Metaplex metadata account when one exists, else a
mint-address-prefix fallback (the Ethereum leg's bytes32 fallback, in
Solana form). An unwrap intent binds a fresh Sequentia address to a Solana
destination; any Solana-bridged asset arriving there is released from the
treasury after the Bitcoin-anchor finality gate (creating the recipient's
associated token account when needed), then destroyed. All Solana-side
transaction building (legacy transactions, program-derived addresses with
the ed25519 on-curve check, SPL transferChecked, ed25519 via
node:crypto, base58) is hand-rolled in daemon/lib/sol.js and
byte-for-byte verified against @solana/web3.js and @solana/spl-token
during development; the e2e mock RPC independently decodes and
signature-checks every submitted transaction.
A unified stablecoin arrives from several chains, and each chain's deposits
are escrowed there. Circle adopts a bridged USDC by burning a single escrow,
so the daemon keeps the escrow in one place: whatever the Solana treasury
holds beyond a working float (solFloatUnits, kept for releases on Solana)
is moved into the Ethereum vault with Circle's own Cross-Chain Transfer
Protocol. The USDC is burned on Solana and minted by Circle into the vault,
so nothing but native USDC ever backs the asset.
Each move is recorded before anything is sent, and the Solana burn's
signature is persisted before broadcast, like every other outbound Solana
transfer. The daemon fetches Circle's attestation and relays the mint on
Ethereum itself. When the vault can receive CCTP (receiveCctp), the burn
names the vault as the only relayer and the relay goes through it, so the
arrival is recorded on the vault as RebalancedIn; an older vault simply
receives the mint. Either way the message's nonce says whether it was
already relayed. Between the
burn and the mint the amount is in transit: the reserves page
(inTransitAtoms, with each Solana burn listed) and the supply invariant
count it as backing, so a move in flight never reads as a shortfall.
Each Solana burn leaves a small account holding Circle's record of the message, paid for from the treasury. Circle's program lets the payer close it five days after the burn; the daemon does so automatically, with the same persist-before-broadcast guard, and the rent returns to the treasury.
USDC.e is one asset whichever chain the dollar came from, and Circle's Cross-Chain Transfer Protocol (CCTP) connects the bridge to every chain Circle supports, not only Ethereum and Solana:
- In. On any chain in the bridge's CCTP list (
GET /api/status,cctp), a user burns USDC with Circle's TokenMessenger, naming the deposit vault as mint recipient and as the only relayer, with hookDatacompages:deposit:<Sequentia address>. The page builds that call and reports the burn (POST /api/cctp/deposit); the daemon waits for Circle's attestation and relays it through the vault'sreceiveCctp, which mints the USDC into the vault and emits an ordinary deposit. From there it is minted as USDC.e like any other deposit. A burn with an invalid Sequentia address, or hookData the vault does not recognise, is refunded to the chain and sender it came from (refundViaCctp). - Out. A redemption address can name another CCTP chain as its
destination (
POST /api/redeemwithdestinationDomain). USDC.e returned to it is paid out there: the vault burns the USDC (releaseViaCctp) for minting to the recipient, and the redemption record carriescctpOutwith Circle's attestation once it exists, so the recipient (or anyone) can complete the mint on that chain withreceiveMessage. Any other asset returned to such an address is paid on Ethereum, to the same address. - Solana. A USDC.e redemption to Solana that the Solana float cannot cover is paid out of the Ethereum vault the same way, and the daemon relays the mint on Solana itself (creating the recipient's USDC account first when it has none).
USDT0 is Tether's USDT on LayerZero's Omnichain Fungible Token (OFT)
standard. On Ethereum its OFT is an adapter that locks native USDT, so USDT0
sent to Ethereum arrives as ordinary USDT. CompagesOftReceiver
(contracts/src/CompagesOftReceiver.sol) takes such arrivals into the vault.
It is a separate contract so that the vault, which is not upgradeable, can
take them without being replaced.
- Sending. On any chain USDT0 reaches, the sender calls the OFT's
sendwith the receiver as recipient, a compose message ofcompages:deposit:<Sequentia address>(or exactlycompages:rebalancefor liquidity), and executor options giving the compose at least 400,000 gas. LayerZero credits the USDT to the receiver, then delivers the compose message to itslzCompose; anyone can trigger that delivery. - Arriving.
lzComposeaccepts a call only from the configured LayerZero endpoint, with a message the configured OFT queued, for an amount the receiver actually holds, and each transfer's guid only once. A deposit goes into the vault through its ordinarydepositToken, so the vault emitsDeposited(withfromthe receiver) under its own deposit counter, and the receiver emitsOftDeposit(nonce, srcEid, sender, guid, amount)under the same number, naming the source chain (its LayerZero endpoint id) and the account the USDT came from. Liquidity goes into the vault by plain transfer, and the receiver emitsRebalancedInwith the endpoint id as its source. Anything else, a deposit the vault refuses under its deposit rules included, also goes into the vault and is reported withOftUnrecognized(srcEid, sender, guid, amount, composeMsg)for refunding. Deposits and unrecognised arrivals wait out a deposit pause, and nothing lands while the receiver is disabled: such a compose stays queued at the endpoint, its USDT waiting in the receiver, and is delivered again later. The amount reported is the vault's measured balance change. - Roles. The receiver has no keys of its own; it reads the vault's. The
vault's owner points it at the endpoint and the OFT with
setOft(endpoint, oft), which checks that the OFT names that endpoint and reads the token from it, turns it on withsetEnabled(true), and moves anything that reached it without a compose message into the vault withsweepToVault. The vault's guardian or owner turns it off. Tokens leave the receiver only into the vault. - Leaving. USDT held by the vault is paid out like any other token, with
releaseandrefundon Ethereum.
Like every bridged asset, USDT arriving this way is one asset among equals on Sequentia, with no special standing. The daemon does not watch a receiver, and no USDT0 asset is issued on Sequentia until Tether sets up the asset.
CompagesVault (contracts/src/CompagesVault.sol) holds the Ethereum-side
escrow. It is deliberately not upgradeable; a new version is a new
deployment. Its VERSION constant says which one an address runs.
contracts/deployments/sepolia.json records each Sepolia vault's address,
deploy block and transaction, source commit, compiler settings and constructor
arguments; every one is source-verified on Sourcify and Blockscout, and
rebuilding its source commit reproduces the deployed bytecode exactly.
Roles. Three keys, set at deployment:
| Role | Intended holder | Can |
|---|---|---|
owner |
a Safe or a cold key | set the other roles and every limit, unpause, reinstate, amend or discard cancelled releases, move unreserved escrow with rebalanceOut and add ether escrow with fundEther, configure CCTP and the stablecoin burner. Transferred in two steps (transferOwnership, then acceptOwnership by the new owner) |
operator |
the daemon's hot key | release, refund, releaseViaCctp and refundViaCctp, nothing else |
guardian |
an incident-response key | pauseDeposits, pauseReleases and cancelRelease; never unpause, never move funds |
contrib/safe-owner.md moves the owner role to a Safe multisig (and back), and explains why the guardian stays a single key and the operator a hot one. The live vault's owner is a 2-of-3 Safe, 0xc5540Be5eDc4D06459964dE061aFAB5c3b0025c0, so every owner action needs two signers.
Rate limit and queue. Each token (address zero for ether) has a token
bucket set by setReleaseLimit(token, capacity, refillPerSecond). A release
or refund that fits in the bucket pays at once; one that does not is queued
for releaseDelay seconds (between one hour and 30 days) and emits
ReleaseQueued. After the delay anyone may call executeRelease(id). During
it the guardian or owner can cancelRelease(id). A cancelled release stays
with the owner, who can reinstateRelease(id) (queued again with a fresh
delay), first amendCancelledRelease its destination (a different recipient,
or switching between a direct and a CCTP payout, keeping the token and
amount), or discardCancelledRelease(id) it for good once releaseDelay has
passed since the cancel (releaseDiscardableAfter(id)), which is how a bogus
entry queued with a stolen operator key is cleared. A token with no bucket has capacity
zero, so every payout of it is queued. A newly configured bucket starts empty
and fills at its refill rate, and reconfiguring one never tops it up. An id is
marked processed the moment it is paid or queued, and a cancelled id stays
spent. availableToRelease(token) and queuedRelease(id) show the current
state.
Queued releases and cancelled ones the owner may still reinstate are reserved, like owed amounts: no immediate payout and no rebalance can spend the escrow they will need. A release is refused outright, paid now or queued, when the unreserved balance cannot cover it, so the queue never promises more than the vault holds and a stolen operator key cannot reserve a token away. Queued releases draw on the escrow first come, first served: executing one holds back only owed and cancelled amounts.
Owed payouts and claims. Before paying or queuing, the vault requires its unreserved
balance (balance minus what it owes claimants and what queued and cancelled
releases hold, shown by unreservedBalance(token)) to cover the amount, and
otherwise reverts with InsufficientVaultBalance so the payout can be retried
later. If the transfer itself fails, the amount becomes owed to the recipient
(ReleaseDeferred), stays reserved, and the recipient calls
claim(token, payTo) to withdraw it to any address. Only the recipient itself
can claim, so a contract that can neither accept the payout nor make calls can
never collect what it is owed. Released, Refunded and the CCTP payout
events are emitted only when funds actually leave.
Adding escrow. Tokens arrive by plain transfer. Ether has no such path,
since the vault has no receive(): the owner adds it with fundEther(),
which emits RebalancedIn with the source domain FUNDING_DOMAIN
(type(uint32).max) and creates no deposit.
Deposit rules. The owner can set a per-token minimum (setMinDeposit), a
cap on the vault's balance after a deposit (setDepositCap, zero for none),
and refuse a token outright (setTokenBlocked). Deposits credit the balance
actually received, so fee-on-transfer tokens bridge the post-fee amount.
Rebasing tokens are not supported: a balance that shrinks can leave owed
amounts unbacked.
USDC over CCTP. Once the owner points the vault at Circle's CCTP V2
contracts (setCctp(tokenMessenger, messageTransmitter, usdc)), USDC can
arrive from and leave to other chains while staying in this one escrow:
- Inbound. A burn on the source chain names the vault as
mintRecipientand asdestinationCaller, and anyone relays it withreceiveCctp(message, attestation). ThedestinationCallermatters: a burn that leaves it empty can be relayed straight to Circle's transmitter, around the vault, and its USDC then arrives with no event, as an unaccounted donation. Its hookData decides what it is. The ASCII bytescompages:deposit:followed by a Sequentia address (14 to 120 bytes) make a deposit, which emitsDeposited(withfromzero) andCctpDeposit(nonce, sourceDomain, sender, cctpNonce, amount)under the same deposit number. Exactlycompages:rebalanceis liquidity from another escrow and emitsRebalancedIn. Anything else, a malformed deposit included, is still received, because nothing but the vault could ever complete it, and is reported withCctpUnrecognized(sourceDomain, sender, cctpNonce, amount, hookData)so it can be refunded withrefundViaCctp; like a deposit, it waits out a deposit pause, so nothing new becomes burnable while the supply is locked. A burn that names the vault asdestinationCallerbut mints to someone else is relayed too, for the same reason: it credits nothing, emitsCctpForwarded(sourceDomain, mintRecipient, cctpNonce), and reverts if it would change the vault's USDC balance. The vault relays only messages addressed to Circle's TokenMessenger. The amount credited is the USDC balance change across the mint. A deposit is refused while deposits are paused and can be relayed again afterwards; the deposit minimum, cap and token block do not apply, since the dollars are already minted and the daemon refunds what it cannot bridge. - Outbound.
releaseViaCctp(amount, destinationDomain, mintRecipient, redemptionId, maxFee)burns USDC here for minting on another chain, under the same replay map, USDC rate limit, queue and pause asrelease, at Circle's finalized threshold. It emitsReleasedViaCctp, andrefundViaCctpemitsRefundedViaCctp. The vault approves exactly the amount for each burn and resets the approval afterwards.
Supply lock and stablecoin hand-off. Pausing both deposits and releases
freezes the escrow against the circulating supply. With the supply locked, the
burner named by setStablecoinBurner can call burnLockedUSDC(), which burns
the stablecoin's whole balance except what is committed to individual users:
amounts owed to claimants, releases still in the queue, and cancelled releases
the owner may still reinstate. Those are paid out as normal afterwards, and a
guardian cancel can never make a user's escrow burnable. The burn uses the
token's own burn, so on USDC the issuer first makes the vault a minter; see
"Rehearsing the hand-off to Circle".
Whoever holds a Sequentia asset's reissuance token can mint any amount of it.
Compages keeps each token in a mint limiter, a covenant from
sequentia-contracts
(templates/mint_limiter). The covenant lets the operator key mint at most an
allowance per window of blocks, the count carried in the covenant's own coin,
and lets a cold key stop it: the cold key's signature over a message that names
no coin applies a brake to whichever coin is current, the cold key lifts it,
and once the brake has held a long delay the cold key can take the token. The
daemon holds the operator key's mnemonic; the cold key never reaches the host.
It mirrors, on Sequentia, what the vault's rate limit and guardian do on
Ethereum.
Every reissuance is a spend of the covenant, built and signed by
mint-limiter (sequentia-contracts, tools/mint-limiter) with no node access
and no credential: the daemon hands it the coins, has the wallet sign the fee
coin, records the transaction id, then broadcasts. A mint the covenant would
refuse waits instead: over the window's allowance until the next window, on a
braked limiter until the brake is lifted. A new asset's first deposit issues
the asset and moves its new token straight into a limiter; a unified
stablecoin's ceremony does the same. The daemon follows the limiter's coin
through spends it did not make (the brake, its release, a recovery) and halts
an asset whose limiter was spent by a mint it did not sign. Each asset's
limiter (address, coin, state, allowance, window, brake) is in /api/assets
and /api/por.
docs/mint-limiter.md is the runbook: making the keys,
moving a token in, the brake, lifting it, and recovery.
An Arca operator can serve a bridged asset issuer-operated: it mints each
batch's value in the round that makes the batch, through the asset's mint
limiter, and every sweep of such a batch only burns. Until a batch is swept
its units sit beside the coins its holders gave up for it, with no deposit
behind them. Every reserve check therefore deducts, before comparing supply
with escrow, the smaller of the units live under the operator's burn-only
sweeps and what its rounds minted less what it burned (sweeps, claims, the
holders' fees and abandoned coins burned with them): so every unit issued
outside a deposit must be a listed round's, and every unit outside the
batches must be backed. Each figure comes from the operator's
issuer_supply report and is read again on the chain: each mint, burn and
live output, the output where the batch's published tree puts it.
The daemon follows a limiter mint of the operator's once the operator lists
it; any other mint it did not make halts the asset as before, and counts in
the supply. docs/issuer-operated.md says what
each check reads, what the deduction cannot see, and how to set it up
(issuerOperated in the daemon's, the watcher's and the snapshot tool's
configuration).
Releasing on Ethereum or Solana is irreversible, so the burn that triggers it must be final. On Sequentia, Bitcoin anchoring is the supreme consensus rule: every Sequentia block references a Bitcoin block, and if that Bitcoin block is reorged the Sequentia block is discarded in real time, no matter how many Sequentia blocks were built on top. A burn buried under many Sequentia blocks can therefore still be undone by a Bitcoin reorg.
So the release gate is the burn's Bitcoin-anchor depth, not a Sequentia
block count: depth = getanchorstatus.anchorheight − getblockheader(burnBlock).anchorheight,
required to reach btcAnchorConfirmations. Because consecutive Sequentia
blocks share a Bitcoin anchor, this depth advances only as Bitcoin advances,
which is precisely the finality that protects the release. The gate also
requires the node's anchorstatus to be "ok" and, when the node reports it,
the burn block to be committee-certified. If the node cannot report its
anchor status, or does not validate anchors, nothing is final and releases
wait. Only a local test chain that has no anchoring at all sets
allowUnanchoredFinality, which falls back to a count of
seqConfirmations Sequentia blocks.
Choosing btcAnchorConfirmations: it must exceed the deepest reorg of the
anchor chain you are willing to tolerate. The live deployment anchors to
Bitcoin testnet4 and sets it to 100, because testnet4 permits
unusually deep reorgs (its min-difficulty rule lets a miner rewrite long
stretches). A chain anchored to Bitcoin proper could use a much shallower
depth (the config default is 3). Deeper means slower redemptions (each
confirmation is about one Bitcoin block), which is the honest cost of
anchored finality.
Each bridged asset is registered in the
Sequentia Asset Registry
with an origin-suffixed ticker (.e marks it Ethereum-bridged, .s
Solana-bridged; the suffix avoids colliding with native assets) and the name
<token name> (<chain name>), e.g. Ether (Sepolia) as ETH.e and
SOL (Solana devnet) as SOL.s. Unified stablecoins are the exception: one
ticker (USDC.e, EURC.e) whichever chain the deposit came from, because
there is exactly one asset per coin. The asset is issued committed to
SHA256(canonical-JSON(contract)) as its contract hash, so the metadata is
bound on-chain and independently verifiable, not just asserted by the
operator. Registration is best-effort and retried; it never blocks a mint.
- The bridge charges no fee of its own. Users pay their own Ethereum gas (deposit, approve) and the Sequentia network fee of the transfer to the redemption address; the operator pays everything else (issuance, reissuance, delivery, the redeem-side burn, and release gas on Ethereum).
- Sequentia has an open fee market: fees are payable in any accepted asset
and no asset (including the Sequence token) is privileged. The daemon pays
every Sequentia fee in the single asset named by
seqFeeAsset, whatever the operator chooses; it never needs the policy asset. Pinning the fee asset explicitly is also necessary because the wallet would otherwise default the fee to the asset being sent, and a freshly bridged asset has no exchange rate on the node yet. The end-to-end test proves this by funding the bridge with only a non-policy fee asset and asserting its policy-asset balance stays zero throughout.
- Burning in any fee asset:
destroyamountonly pays its fee in the policy asset, so whenseqFeeAssetis set the redeem-side burn is built as a raw transaction (aburnoutput for the bridged asset plus a fee output inseqFeeAsset), blinded, signed and broadcast by the daemon (daemon/lib/bridge.js,buildBurn). - Broadcast verification: a txid returned by the wallet is never taken as
proof of broadcast. After every mint, send and burn the daemon establishes
one of three answers: the transaction is in the mempool or a block; it is
provably absent (the wallet accepted
abandontransaction, which it refuses for anything in the mempool or a block), which makes a retry safe; or the node could not say. In that last case the record waits asunresolvedand is asked again every tick, because retrying a transaction whose fate is unknown is exactly how a bridge mints or pays twice. - Crash safety: every irreversible step is bracketed by a persisted
marker in the state file, and the transaction id is recorded before its
outcome is checked, so after a crash the chain answers whether it landed.
Burns go further: the signed burn is persisted before broadcast and
re-broadcast as-is after an interruption, which can never burn twice. On
Ethereum the vault's
processedRedemptionsis the authority on whether a payout landed. Only a step interrupted before its transaction id was recorded, or one the node cannot settle forunresolvedHours, becomes a*_manualcase for the operator. - Bounded waits: every call to a node, an RPC provider or a service has a
timeout, and an Ethereum payout that sits unmined for
ethStuckMinutesis replaced at the same nonce with higher fees, so one stuck call cannot stall every leg of the bridge. - Durable state: each save is flushed to disk (file and directory) before
the daemon acts on it, and a copy per day for the last 14 days is kept in
snapshots/beside the state file.
The daemon serves the static web app and a JSON API from the same port
(apiPort, default 9950). The live instance is reverse-proxied under
https://sequentiatestnet.com/bridge/. CORS is permissive; the API holds no
secrets, and the only public mutating calls create deposit or redemption
intents, rate-limited per client (intentLimitPerHour); none moves funds.
The heavier reads (por, por/history, seqaddress, token, health) have their own
per-client limit (readLimitPerHour), and IPv6 clients are counted per /64.
Error text in responses never carries an RPC URL. Redemption records report
their progress toward finality as numbers (finalityProgress:
{depth, need, kind}), so a page can draw it.
| Method and path | Purpose |
|---|---|
GET /api/status |
Bridge configuration and counters: chain ids, vault address, confirmation depths, number of bridged assets, deposits, redemptions |
GET /api/assets |
All bridged assets: token, symbol, decimals, Sequentia asset id, ticker, contract hash, circulating amount (mintedSats), and whether the asset is supervised (freezable by its issuer and never blinded) |
GET /api/por (optionally ?asset=<id|symbol>) |
Proof of reserves per bridged asset: escrow on each source chain against circulating Sequentia supply, read from the chains rather than the daemon's ledger; an unmeasured side is null, never zero. A unified asset also lists its CCTP consolidations in flight (inTransit) and those of the last week (recentTransfers, each with its Solana burn and CCTP nonce). reconciliation says where the supply is: what the bridge holds (nodeWalletAtoms, and leafWalletAtoms: its leaf wallet on the chain, in boards and in leaves, with a leaf mint still in the mempool), what users hold, what is owed to deposits not delivered yet (owedAtoms) and what users returned that is not released yet (returnedAtoms); reconciles is true when escrow equals users' holdings plus owed plus returned, and leaves lists the boards in flight and the leaves paid out with their margins |
POST /api/leaf/intent {"request": "arca:...", "token": "0x...", "fallbackAddress": "..."} |
Bind an Arca receive request to a fresh Sequentia address for a deposit paid as a leaf (see "Deposits paid as Arca leaves"); token (or the request's asset) names what is deposited, fallbackAddress is optional. Asking again with the same request and fallback answers the same address; another fallback makes another intent, and the request is paid once through whichever intent a deposit reaches first |
GET /api/leaf/intent/<seqAddress> |
A leaf intent: its asset, operator, the request's key and lapse, its fallback, and every deposit made to it |
GET /api/por/history |
The index of signed reserve snapshots (see "Signed reserve snapshots"): every snapshot's height, payload hash and link. 404 before the first snapshot, and when porHistoryDir is not set |
GET /api/por/history/<height> |
One signed reserve snapshot, byte for byte as it was signed; the height is a plain decimal number |
GET /api/token/<address|eth> |
Metadata for a token and whether it is already bridged (used by the front-end's token lookup) |
POST /api/redeem {"ethAddress": "0x..."} |
Create a redemption intent; returns the Sequentia address to send bridged assets to |
GET /api/redeem/<seqAddress> |
A redemption address's bound Ethereum address and the status of every redemption seen on it |
GET /api/deposit/tx/<ethTxHash> |
Look up deposits by their Ethereum transaction hash (used to track and resume deposits) |
POST /api/btc/wrap {"seqAddress": "..."} |
Bitcoin deposit address for a BTC → SBTC wrap (proxied to the sbtc-bridge) |
POST /api/btc/unwrap {"btcAddress": "..."} |
Sequentia return address for an SBTC → BTC unwrap (proxied to the sbtc-bridge) |
GET /api/btc/wrap/<depositAddress>, GET /api/btc/unwrap/<sbtcAddress> |
Every transfer sent to a BTC deposit address or an SBTC return address: amount, confirmations, stage, and the credit or release txid (proxied to the sbtc-bridge) |
POST /api/sol/wrap {"seqAddress": "..."} |
Solana deposit address for a SOL → SOL.s wrap (the Sequentia address is validated up front) |
GET /api/sol/wrap/<solAddress> |
A wrap intent's bound Sequentia address and the status of every deposit seen on it |
POST /api/sol/unwrap {"solAddress": "..."} |
Sequentia return address for a SOL.s → SOL unwrap |
GET /api/sol/redeem/<seqAddress> |
A Solana unwrap address's bound Solana destination and the status of every redemption seen on it |
GET /api/sol/intents |
The Solana treasury and every deposit address the bridge has handed out, for anyone checking the Solana escrow |
POST /api/cctp/deposit {"sourceDomain": 6, "txHash": "0x..."} |
Report a USDC burn made on another CCTP chain for relaying (see "USDC from and to other chains") |
GET /api/cctp/deposit/<domain>/<txHash> |
Where a reported burn stands (attesting, relaying, relayed, not_found, not_for_bridge) and the deposit it became |
GET /api/redeem/by-eth/<ethAddress> (optionally ?domain=<cctpDomain>) |
The redemption address bound to an Ethereum address, and its redemptions. Each Ethereum (or Solana) destination has one redemption address: asking again returns the same one. An address can have one per payout chain; ?domain= picks the one paying out on that CCTP domain (0 is the vault's own chain) |
GET /api/seqaddress/<address> |
Whether an address is a valid Sequentia address, whether it is a blinded one, and for a blinded one its unconfidential form (where supervised assets are delivered); checked before any funds move |
GET /api/health |
The operator's health report (see "Watch it and act on what it reports"); HTTP 503 while anything critical is wrong |
/api/admin/* |
Operator actions (records, resolve, halt, unhalt, retire-asset, the mint limiter's moves, leaf-pay); exists only with adminToken, and answers 404 without it |
Deposit records move through the statuses minting, mint_retry and
send_retry (a safe retry, with backoff), unresolved (a chain write whose
outcome the node could not confirm yet; re-checked every tick), minted
(delivered; watched until the delivery is final under Bitcoin anchoring;
deliveredTo names the address used when it differs from the one given),
delivery_reorged (a delivery later displaced on Sequentia), refund_pending,
refunding, refund_queued (over the vault's rate limit, waiting out its
delay), refunded, refund_cancelled (a queued refund the guardian
cancelled), refund_discarded (a cancelled refund the owner discarded on the
vault; an operator decides), refund_failed_manual, and failed_manual (paused
for operator review; Solana deposits use dust_manual instead of the refund
states). A deposit paid as a leaf goes from minting through leaf_minted
(in the leaf wallet, waiting its turn to board), leaf_boarding (the board
in flight) and leaf_paying to minted with leaf.paid, or rests in
leaf_held (its request can no longer take the leaf; an operator pays a new
one). A deposit of a halted asset waits in mint_retry with a waiting
reason; a refund that comes due while its asset is halted stays
refund_pending. Redemption records move through awaiting_finality,
awaiting_liquidity (the payout chain's treasury is short; a waiting
reason says of what), halted, new, releasing, release_paused (the
vault's releases are paused), queued (over the vault's rate limit; the
daemon executes it once executeAfter passes, in block time),
release_cancelled (a queued release the guardian cancelled; an operator
decides), release_discarded (a cancelled release the owner discarded on
the vault; an operator decides), released, destroy_pending, destroying, done,
plus the terminal dust_ignored, ignored_unknown_asset,
ignored_wrong_network (an asset returned to the wrong leg's address),
release_failed_manual (the vault refuses the recipient address) and
destroy_manual. A payout the recipient refuses (a contract that rejects
plain ether, a blocklisted address) is still final: the record carries
deferred: {to, amount}, the amount is owed on the vault, and the recipient
claims it to any address with claim(token, payTo).
Try it against the live instance:
curl -s https://sequentiatestnet.com/bridge/api/status
curl -s https://sequentiatestnet.com/bridge/api/assets
Requirements: Node.js 20+ for the daemon, Foundry
for the contract, a synced Sequentia node with a funded wallet, and an
Ethereum RPC endpoint that supports eth_getLogs over block ranges.
git clone --recurse-submodules https://github.com/ConcatenaLabs/compages.git
cd compages/contracts
OWNER=0x... OPERATOR=0x... GUARDIAN=0x... RELEASE_DELAY=86400 \
forge script script/Deploy.s.sol --rpc-url $ETH_RPC_URL \
--private-key $DEPLOYER_KEY --broadcast
| Variable | Meaning |
|---|---|
OWNER |
The owner: a Safe or a cold key. Holds every administrative power |
OPERATOR |
The daemon's hot key, the address of operator.key |
GUARDIAN |
The incident key that can pause and cancel queued releases |
RELEASE_DELAY |
Seconds a payout over the rate limit waits before it can be executed (3600 to 2592000: one hour to 30 days) |
The deployer holds no role. Until the owner configures a release limit, every
payout is queued, so the owner's next steps are setReleaseLimit for each
token the bridge pays out (address zero for ether) and, for USDC over CCTP,
setCctp with Circle's TokenMessengerV2, MessageTransmitterV2 and USDC
addresses on that chain. The owner can later rotate the operator
(setOperator) and guardian (setGuardian), transfer ownership in two steps,
and pause or unpause deposits and releases (see "The vault contract").
cd ../daemon
npm install
cp config.example.json config.json # edit, see below
echo <operator-private-key-hex> > operator.key
node compagesd.js config.json
Configuration reference (daemon/config.example.json):
| Key | Meaning |
|---|---|
ethChainName, ethChainId |
Display name and chain id of the Ethereum network (checked against the RPC at startup) |
ethRpcUrl |
Ethereum JSON-RPC endpoint (must support eth_getLogs) |
vaultAddress, vaultDeployBlock |
The primary CompagesVault and the block to start scanning from |
vaults |
Optional list of {address, deployBlock, version}; the daemon watches every vault in it (vaultAddress stays the primary). Omit to watch vaultAddress alone. version pins the vault's interface version (3 for a vault with VERSION(), 1 for an older one) instead of asking the vault at startup; an address missing from this list and from vaultAddress is never acted on |
depositVault |
The vault the web page sends new deposits to, when it is not vaultAddress. vaultAddress never changes once deposits exist: deposit records of the primary vault are keyed by their bare number. A token's first deposit fixes which vault holds its escrow and pays its redemptions |
ethFinality |
finalized (default): a deposit mints once Ethereum finalizes its block, so no Ethereum reorg can undo a deposit that was already minted. confirmations: after ethConfirmations blocks instead, for local test chains |
ethConfirmations |
Confirmations before a deposit is processed when ethFinality is confirmations |
ethLogChunk |
Max block range per eth_getLogs call |
operatorKeyFile |
File containing the operator's private key (never commit it) |
seqRpcUrl |
Sequentia node RPC, http://user:pass@host:port |
seqWallet |
Node wallet name; auto-loaded at startup if on disk |
seqChainLabel |
Label mixed into redemption ids (prevents cross-chain replay) |
seqConfirmations |
Sequentia confirmations; also the finality count under allowUnanchoredFinality |
allowUnanchoredFinality |
For a local test chain with no Bitcoin anchoring only: treat a burn as final after seqConfirmations blocks. Unset (the default), a node that cannot report or does not validate anchors makes every release wait |
btcAnchorConfirmations |
Bitcoin-anchor depth required before a release (see "Finality") |
registryUrl, registryAdminToken, assetDomain |
Asset Registry endpoint, optional admin token, and the entity domain written into asset contracts |
esploraUrl |
Indexer used to read the circulating supply of assets this bridge did not issue (SBTC on the reserves page). Without it their supply is reported as unknown, never as zero |
seqFeeAsset |
Asset id or label the bridge pays all Sequentia fees in (any accepted fee asset the wallet holds) |
mintLimiter |
Every reissuance through a mint limiter (see "The mint limiter" and docs/mint-limiter.md): tool (the mint-limiter binary, in the sequentia-contracts checkout it was built in), operatorMnemonicFile, coldKey (the cold key's x-only public key), windowBlocks, recoveryDelay (units of 512 seconds), defaultAllowance and allowances (atoms per window, by ticker, symbol or mapping key). Unset, the daemon reissues from its wallet |
leafWallet |
Deposits paid as Arca leaves (see "Deposits paid as Arca leaves" and docs/leaf-deposits.md): arca (the arca command line, at the operator's revision; one with landing keys to pay a supervised asset), datadir (the bridge's own leaf wallet, made with arca create on the operator), feeAsset (the accepted asset its boards pay fees in, held on the chain by the leaf wallet; default seqFeeAsset), feeTopUpBoards (when feeAsset is seqFeeAsset, the daemon keeps the leaf wallet's fee coins at this many 1,000-vbyte fees at the node's rate, about three boards each, from the node wallet's own stock, topping up when they fall below a quarter of it; default 50, 0 to fund it by hand) and feeCheckMs (default 300000), intentLapseSeconds (default 172800), landingLapseSeconds (default 21600), syncMinMs, syncMaxMs, syncBusyMs, timeoutMs. Needs mintLimiter. Unset, /api/leaf/* answers 404 |
seqBlockMs |
The Sequentia block interval in milliseconds (default 60000), used to time a mint that waits for the next window |
sbtcBridgeUrl, sbtcBridgeToken |
The sbtc-bridge custody service behind /api/btc/* (omit the URL to disable the Bitcoin leg); it decides when a deposit is credited, and reports it per address |
solRpcUrl, solChainName, solChainLabel |
Solana JSON-RPC endpoint and naming for the Solana leg (omit the URL to disable it) |
solGenesisHash |
Expected cluster genesis hash, verified before the leg acts (the Ethereum chain-id check's Solana equivalent) |
solKeyFile |
32-byte hex seed for the Solana treasury and deposit-address derivation; generated on first boot, never commit it |
solWatchDays |
How long a wrap intent's deposit address is polled (default 7 days); re-requesting a wrap for the same Sequentia address revives it |
solMinReleaseSats |
Smallest SOL.s return that is released (default 100000 sats = 0.001 SOL, clear of Solana's rent-exempt minimum) |
cctp |
Circle's CCTP V2 for the unified stablecoins: consolidating Solana escrow into the Ethereum vault, USDC in from and out to other CCTP chains. enabled, chains (the other chains, each {domain, name, chainId, usdc, rpc, explorer}; the testnets by default), tokenMessengerEvm (Circle's TokenMessengerV2, the same address on every EVM chain), inboundGiveUpHours, messageTransmitter (Circle's MessageTransmitterV2 on the Ethereum chain), irisUrl (Circle's attestation service; the sandbox by default), assets (default ["USDC"]), solFloatUnits (what stays on Solana for releases there, default 5 USDC), minConsolidateUnits, consolidateEveryMinutes, stuckHours |
unified |
Unified stablecoins, keyed by symbol: name, ticker, precision, supervision and the sources (one per chain) that all mint into the one asset (see "Unified stablecoins") |
unifiedIssuerPubkey |
Pinned 33-byte compressed pubkey the bridge wallet controls; hashed into every unified asset id and later authorizes handing the asset to its issuer. Generate once, back up, never change |
supervision (per unified asset) |
enabled issues the asset as a node-level supervised asset; pause additionally allows stopping every holding. Both permanent. operationalKey/recoveryKey pin the public keys; unset, the daemon derives them from the node wallet once |
btcChainName |
Display name of the Bitcoin network behind the SBTC leg (default Bitcoin testnet4) |
webDir |
Directory of the static web app to serve (default: the repository's web/) |
apiHost, apiPort |
Where the API + web app listen |
pollIntervalMs, solPollIntervalMs |
Interval of the main loop and of the Solana leg's own loop |
adminToken |
Enables /api/admin/* and admin.js for anyone presenting it. Unset, the admin API does not exist |
alertUrl, alertToken, alertCooldownMinutes |
Where alerts are POSTed (an ntfy topic URL, or anything that takes a plain-text POST), an optional bearer token, and how often an unchanged alert repeats (default 360). An alert the endpoint did not accept is retried after five minutes. Unset, alerts go to the log only |
porHistoryDir |
Directory the reserve snapshot tool writes to (its snapshotDir); served read-only at /api/por/history. Unset, those paths answer 404 |
trustProxy, intentLimitPerHour, readLimitPerHour |
Take the client address from X-Forwarded-For (only behind a proxy you run), how many intents one client may create per hour (default 30), and how many of the heavier reads it may make per hour (default 1200) |
solMaxWatchedIntents, maxNewAssetsPerDay |
Caps on Solana deposit addresses watched at once (default 1000) and on newly bridged tokens issued per day (default 20) |
retryHours, unresolvedHours |
How long a safe retry, or an unconfirmable transaction, keeps being tried before it becomes an operator case (default 24 each) |
ethStuckMinutes, ethTxWaitMs |
When an unmined payout is replaced at the same nonce with higher fees (default 10), and how long one send waits for mining (default 180000) |
minOperatorGasWei, minFeeAssetBalance, minSolTreasuryLamports |
Balances below which the health report and alerts warn (defaults 0.02 ETH, 1 unit, 0.05 SOL) |
phaseStaleMinutes, invariantIntervalMs, gapCheckMinutes |
When a loop phase that keeps failing is reported (default 10), how often supply invariants are checked (default 60000) and how often vault deposit counts are reconciled (default 10) |
stateFile |
Path of the JSON state file |
The Sequentia wallet named in seqWallet must hold enough of seqFeeAsset
to pay Sequentia fees, the operator's Ethereum account needs gas for releases
and refunds, and the operator key must match the vault's operator(). The
daemon refuses to start when the operator key does not match or the Ethereum
chain id differs from ethChainId, and logs the operator's gas balance when
it starts. Both balances are then watched by the health report, which warns
below minOperatorGasWei and minFeeAssetBalance.
The repository ships no unit file; a minimal one looks like this (adjust user and paths):
[Unit]
Description=Compages bridge daemon
After=network-online.target
[Service]
User=compages
WorkingDirectory=/opt/compages/daemon
ExecStart=/usr/bin/node compagesd.js config.json
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.targetThe daemon is crash-safe by design (state file + on-chain replay guards), so
Restart=on-failure is safe.
GET /api/health is the one report an operator needs: when each loop phase
last succeeded, how many records sit in each status and since when, halted
assets, the last supply-invariant check, the balances that stop the bridge
when they run out, and a problems list. It answers 503 while anything
critical is wrong, so an uptime monitor can watch it directly. The same
problems are pushed to alertUrl once a minute, repeated every
alertCooldownMinutes while they last, with one "resolved" note when they
clear.
Supply invariants are checked every minute against the chain: the supply the Sequentia chain reports may never exceed the daemon's ledger, and for assets that keep an escrow ledger, circulating supply may never exceed escrow beyond what is in flight. A breach seen on two consecutive checks halts minting for that asset until an operator clears it, and an escrow ledger that would go negative halts payouts too. A halt is sticky on purpose.
Records that need a person, and halts, are handled with admin.js, which
talks to the running daemon's admin API (enabled by adminToken):
node admin.js health
node admin.js records failed_manual
node admin.js show deposits 12
node admin.js retry redemptions <txid:vout> "checked: the release never landed"
node admin.js delivered deposits 12 <seqTxid> "sent by hand"
node admin.js retire redemptions <txid:vout> "recipient can never accept ether"
node admin.js retire-asset <mappingKey> "issued before the chain reset" # the token's next deposit issues a fresh asset
node admin.js halt <assetId> mint "investigating"
node admin.js unhalt <assetId>
node admin.js limiters # each asset's mint limiter
node admin.js limiter-move <mappingKey> [allowance] # move an asset's token into a new limiter
# (limiter-move.js does it with the daemon stopped)
node admin.js limiter-arm <mappingKey> <armSignature> # brake a limiter with the cold key's arm signature
retry is for when you have checked the chain and nothing from the stopped
step is in flight. Every admin action is recorded in the state file's
adminLog.
watcher/compages-watch.js is an independent check on the bridge, meant to
run beside the daemon but trusting none of its bookkeeping. It reads the
chains through its own endpoints and, once a minute:
- Rebuilds every vault's books, up to Ethereum's finalized block: what
came in (for an ERC-20, every
Transferinto the vault, which also covers CCTP mints and migrations from another vault that emit no vault event; for ether, the vault's own events) against what the vault's payout events say went out. No token may have left a vault in greater amount than entered it, the vault must hold (at that same block) what its events say it holds, and the number of deposit events must equal the vault's own deposit counter. Logs come fromethLogsRpcUrland balances and counters fromethRpcUrl, two different providers, so a log source that drops events is caught by a counter it did not supply. - Checks reserves: for every bridged asset, circulating supply as the
block explorer's indexer counts it (
esploraUrl, issuances minus burns, never the bridge wallet) may not exceed what the source chains hold. A gap must persist forbreachMinutesbefore it counts, since a redemption is paid out seconds before its burn. For an asset its ownissuerOperatednames, supply is read in blocks and what the operator's burn-only batches and rounds account for is deducted first, each item of its report read again on the indexer (docs/issuer-operated.md). - Checks the mint limiters: for every asset, the coin the daemon names
must be the limiter in the state the daemon reports, by a script the
watcher derives itself from the descriptor (
watcher/vendor/, pinned to a sequentia-contracts commit byPIN.json), must hold the reissuance token, and must hold every unit of it the chain has issued: a token unit anywhere else could mint without limit. A braked or recovered limiter is reported; a limiter coin spent while the daemon still names it is reported afterlimiterSpentMinutes(default 15). - Checks deposits paid as leaves: the daemon's reserve report names
every board in flight and what its leaf wallet holds; the watcher finds
each board on its own indexer, holding the asset and amount the daemon
says, and reconciles the escrow and supply it read itself against what
the bridge says it holds, owes and was returned. A board missing or
holding another amount, or an escrow that does not reconcile, for
leafMinutes(default 15) is reported; escrow short of users' holdings plus what the bridge owes is critical. Neither pulls the brake by itself: the bridge's holdings are the daemon's figures, and the supply check above brakes on its own. - Checks the daemon: its
/api/healthmust answer, and not "failing", withindaemonDownMinutes.
Critical findings go to alertUrl, and with daemonAdminToken set the
affected assets are halted in the daemon, which stops their minting and
payouts until an operator clears the halt. With guardianKeyFile set to the
key holding a vault's guardian role, the watcher also pauses payouts on the
vault itself, which holds even if the daemon or its host is what failed; the
guardian can pause and cancel queued payouts and nothing else, and only the
vault's owner can resume. A fault in the watcher's own data source (an RPC
dropping logs) alerts but never pauses. Payouts at or above
largePayout[token] are announced as they happen (announceEveryPayout
announces all of them). A JSON report is served on
127.0.0.1:<statusPort>/status.
cd watcher
npm install
cp config.example.json config.json # then fill in the URLs and tokens
npm test
npm start
A systemd unit for it looks like the daemon's, with
WorkingDirectory=<checkout>/watcher and ExecStart=/usr/bin/node compages-watch.js config.json.
GET /api/por reports what the chains hold at the moment it is asked, which
says nothing about yesterday and could be answered differently to different
people. So the bridge also keeps a public history: once per interval (a day by
default), a snapshot of every bridged asset's circulating supply against its
escrow, each figure pinned to a block anyone can read again, signed by the
bridge's attestation key and linked by hash to the snapshot before it.
reserves/ holds the tool that takes the snapshots (snapshot.mjs) and the
one that checks them (verify.mjs).
The live bridge signs with the attestation address
0x4d66517923cDd6E374969fF68BdedF818415cDfC. Its
history is served at https://sequentiatestnet.com/bridge/api/por/history
and copied, verified, into the public repository
ConcatenaLabs/compages-reserves,
which keeps it available whatever happens to the operator's host.
A snapshot describes one Sequentia height H:
- Sequentia. H, its block hash, time and the genesis hash. For every
bridged asset, the circulating supply at H as the node repository's supply
auditor
(
contrib/asset-supply-audit/audit.py) reconstructs it from blocks 0 to H: issued, reissued and burned atoms, the circulating total, and whether it is exact. Also recorded: the auditor's exit status (0 when every figure is exact, 2 when a blinded issuance or burn makes one only a bound), its arguments, the commit it came from and the sha256 of the file that ran. For an asset an Arca operator serves issuer-operated (issuerOperatedin the configuration), what its batches and rounds account for at H (burnOnly): each output live under its burn-only sweeps at H (made at or below H, and unspent when read or spent in a block above H, its spender named), each of its mints and burns in a block at or below H, and the deduction the verdict makes, the smaller of the live units and the mints less the burns (docs/issuer-operated.md). - Ethereum. Block B, its hash and time, and for every configured vault and
every token the bridge escrows there: the balance at B, a version-3 vault's
reservations at B (
owedTotal,queuedTotal,cancelledTotal), and the backing they leave (balance less reservations, never below zero). A vault with no code at B backs nothing. - Solana. The treasury and every deposit address the bridge has handed
out (the daemon's
/api/sol/intents), and for each mint the balance of each address's associated token accounts under both token programs (its lamports, for SOL), read atfinalizedcommitment, with the slots the reads were answered at and the cluster's genesis hash. USDC that a CCTP consolidation has burned on Solana and not yet minted into the vault still backs the asset. The daemon lists every consolidation of the last week, and each is checked on chain, never taken on the operator's word: the burn must be final, have succeeded, have moved exactly that amount out of the treasury, and have landed no later than the balances were read; and its CCTP nonce, taken from Circle's attestation service, must still be unused on Circle's MessageTransmitter at block B. A burn that passes is counted at the amount it delivers (less Circle's fee); one Ethereum had already received by B is in the vault's balance instead. Every listed transfer appears in the snapshot with the reason it was or was not counted. - Per asset. The escrow total in the asset's atoms (
nullwhen any source could not be read, never zero), the in-transit total, andbacked: true or false when the supply is exact and every source was measured,nullotherwise. - Deposits paid as leaves (
leaves, for an asset the bridge pays as Arca leaves). What the bridge held of the asset (bridgeHeldAtoms,leafWalletAtoms) and owed to deposits not delivered (owedAtoms), every board in flight with its deposit, amount and block height and whether that block is at or below H (atOrBelowH), and the leaves paid out (paidCount,paidAtoms, with theirmarginsAtoms). The leaf wallet cannot be read at a past height, so these are the bridge's figures when the snapshot was taken (reportedAt), labelled so; each board's block is read from the chain, andverify.mjs --seq-rpcreads it again. A leaf is minted per deposit, never ahead of one, sobackedneeds none of these figures. - Provenance. The tool's name, version, repository and commit, the
attester's address, when the snapshot was taken, and
previous: the height and payload hash of the snapshot before it (nullfor the first).
SBTC is not in the snapshots: its reserve is held by the sbtc-bridge custody
service rather than in a vault or treasury of this bridge, and /api/por
reports it live.
- H is the latest multiple of
intervalBlocks(default 1440, a day of 60-second blocks) that is at leastminDepthblocks (default 10) below the tip. It does not depend on when the tool runs, so anyone can recompute it, and a run for an H that already has a snapshot does nothing. - B is the last Ethereum block whose timestamp is at or before block H's time. The tool waits until Ethereum has finalized a block after that time, so B is final and exactly determined (block B+1 is after H). Both sides of the comparison are then measured at the same moment. That matters: a deposit is escrowed before it is minted and a redemption is burned before it is released, so at one moment escrow may exceed supply, and a shortfall is a real one. Measured at different moments, a deposit made after H would cover a shortfall at H.
- Solana has no way to read a balance at a past slot: its RPC answers for
the current state only. The Solana balances are therefore read when the
snapshot is taken, shortly after H, and the snapshot records the slots and
the time and says so (
pastSlotQueryable: false). They can include deposits made after H. A CCTP consolidation in flight at B is counted through its burn and nonce, as above, so a move between escrows reads as neither short nor long.
<H>.json is canonical JSON (object keys sorted at every depth, no
whitespace, integers only, every amount a decimal string) followed by a
newline:
{"format":"compages-reserves-snapshot","hash":"<sha256 of the payload>","payload":{...},
"signature":{"address":"0x...","message":"...","scheme":"eip191-personal-sign","signature":"0x..."},"version":1}
hash is the sha256 of the payload's canonical JSON, which jq -cjS .payload reproduces byte for byte. The attestation key signs, with EIP-191
personal_sign, the message
Compages proof-of-reserves snapshot
Sequentia height: <H>
Payload sha256: <hash>
and payload.attester names the key. Because each payload carries its
predecessor's hash, a snapshot that is removed, reordered or rewritten (even
re-signed) breaks the link of the one after it. A snapshot file is written
once and never replaced: the tool links a complete temporary file into place,
which fails if the name exists, and it refuses to extend a history that does
not verify. A height the tool never ran for (the host was down all day)
stays a visible gap: the next snapshot links to the last one that exists.
index.json lists every snapshot (height, hash, previous, createdAt,
file) and the head. It is rebuilt from the snapshot files on every run and
is not signed; each entry can be checked against the file it names.
With the verify tool (Node.js 20+):
git clone https://github.com/ConcatenaLabs/compages.git
cd compages/reserves && npm install
node verify.mjs https://sequentiatestnet.com/bridge --attester <address>
It fetches the index and every snapshot, and checks each signature against
the pinned address, each payload hash, the whole hash chain, the index, and
that every derived figure (backing, totals, atoms, the verdict) follows from
the raw figures. The target can also be a directory of snapshots, such as a
clone of the mirror, or a single file. Without --attester a snapshot is
checked against the attester it names, which proves it is intact but not who
made it.
--rederive re-reads the latest snapshot's figures (or --height H's) from
endpoints you choose:
node verify.mjs https://sequentiatestnet.com/bridge --attester <address> --rederive \
--eth-rpc <Sepolia RPC that still has the state at B> \
--seq-rpc http://user:pass@127.0.0.1:<port> \
--audit-script <Sequentia checkout>/contrib/asset-supply-audit/audit.py \
--sol-rpc https://api.devnet.solana.com
--eth-rpc checks B's hash, that B is the last block at or before H's time,
and every vault figure at B (an old snapshot needs an archive node).
--seq-rpc checks H's hash and the genesis hash, and with --audit-script
reruns the auditor to H and compares every supply figure; for an
issuer-operated asset it reads each output under a burn-only sweep again
(made at or below H, holding what the snapshot says, live at H; the node
needs -txindex), and --operator <url> checks each against its batch's
published tree as well. --sol-rpc checks
the cluster and every listed CCTP burn, and with --eth-rpc as well whether
each transfer was still in flight at B (--iris-url and --transmitter
override Circle's attestation service and MessageTransmitterV2). The Solana
balances themselves cannot be re-read at a past slot.
Without any of this repository's code, one snapshot checks with jq,
sha256sum and ethers:
curl -s https://sequentiatestnet.com/bridge/api/por/history/<H> > snap.json
jq -cjS .payload snap.json | sha256sum # equals: jq -r .hash snap.json
node -e 'const {verifyMessage} = require("ethers"); const s = require("./snap.json");
console.log(verifyMessage(s.signature.message, s.signature.signature))' # the attestation address
and the message must read exactly as above, with that height and hash.
On the operator's host, beside the daemon:
cd reserves
npm install
node keygen.mjs attestation.key # prints the attestation address
cp config.example.json config.json # then edit, see below
node snapshot.mjs config.json --dry-run # prints what it would sign
node snapshot.mjs config.json
keygen.mjs writes a fresh key (mode 0600) and refuses to replace an
existing one. Back the file up offline and never commit it: whoever holds it
can sign in the bridge's name. The address it prints is the one published
above and pinned in the mirror (ATTESTER in its workflow).
| Key | Meaning |
|---|---|
snapshotDir |
Where the snapshots and index.json are written; the daemon's porHistoryDir points at the same directory |
attestationKeyFile |
The attestation key, as keygen.mjs writes it (never commit it) |
intervalBlocks, minDepth |
Snapshot every this many Sequentia blocks (default 1440), once the height is this many blocks deep (default 10) |
seqRpcUrl |
The Sequentia node, http://user:pass@host:port. The auditor receives the credentials in a private temporary cookie file, never on its command line |
auditScript |
Path to contrib/asset-supply-audit/audit.py in a checkout of the node repository (python, default python3, runs it) |
auditCheckpoint |
The auditor's checkpoint file (default: none, a full scan every time). The first run scans from genesis, at a few hundred blocks a second against a local node; later runs resume and read one interval. The checkpoint is discarded, and the chain rescanned, when the block it ends at is no longer on the chain, when the set of assets changes, or when the last run was interrupted |
auditTimeoutMinutes |
How long one audit may run (default 360) |
daemonUrl |
The daemon's API: the asset list, the Solana deposit addresses, and the recent CCTP consolidations (each checked on chain before it counts) |
ethChainId, ethChainName, ethRpcUrl |
The Ethereum chain (the chain id is checked against the RPC). The RPC must answer for the state at B, which is about as old as H, on every request: an archive endpoint. A load-balanced public endpoint whose backends keep different amounts of history answers some historical calls and not others |
irisUrl, cctpMessageTransmitter |
Circle's attestation service (default: the sandbox) and MessageTransmitterV2 on the Ethereum chain (default: Circle's testnet address), used to tell whether a CCTP consolidation was still in flight at B |
vaults |
Every vault that holds escrow, each {address, version}. version pins the vault's interface version (3 or later for a vault with VERSION() and its reservations; 1 or 2 for an older one) so each run reads only the figures that version has instead of first asking the vault which version it is |
solRpcUrl, solChainLabel, solTreasury, solGenesisHash |
The Solana RPC, the chain label the bridge uses for it, and the treasury and cluster genesis hash, both checked before anything is read |
toolSource, auditSource |
Repository URLs recorded in each snapshot (default the ConcatenaLabs repositories) |
snapshot.mjs exits 0 when it wrote a snapshot or had nothing to do yet, and
1 on any failure, in which case it wrote nothing. Run it from a timer; the
repository ships no unit file, and a minimal pair looks like this (adjust
paths):
# /etc/systemd/system/compages-reserves.service
[Unit]
Description=Compages signed reserve snapshot
After=network-online.target compagesd.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/compages/reserves
ExecStart=/usr/bin/node snapshot.mjs config.json# /etc/systemd/system/compages-reserves.timer
[Unit]
Description=Take the Compages reserve snapshot when one is due
[Timer]
OnCalendar=*:0/10
Persistent=true
[Install]
WantedBy=timers.targetsystemctl daemon-reload
systemctl enable --now compages-reserves.timer
A run every ten minutes costs nothing when no snapshot is due, and takes each
snapshot within minutes of its height becoming eligible, which keeps the
Solana read close to H. A failed run shows as a failed unit
(systemctl --failed) and the next run tries again.
| Path | What it is |
|---|---|
contracts/ |
Foundry project: src/CompagesVault.sol, src/CompagesOftReceiver.sol (USDT0 and other LayerZero OFT arrivals into the vault), unit tests, the deploy script, script/SafeOwner.s.sol (moves the owner role to a Safe), forge-std as a git submodule, and deployments/sepolia.json, the deployed vaults |
daemon/ |
compagesd.js, the Node.js bridge daemon: lib/bridge.js (core logic), lib/limiter.js (the mint limiter), lib/leaves.js (deposits paid as Arca leaves), lib/eth.js (Ethereum side), lib/sol.js (Solana side: RPC client, keys, transaction builder), lib/cctp-sol.js (Circle CCTP V2 on Solana: burn and receive instructions, message parsing, attestation lookup), lib/seqrpc.js (Sequentia RPC), lib/state.js (persistence), lib/api.js (HTTP API + static server), lib/porhistory.js (serves the reserve snapshot history), lib/alerts.js (push alerts); admin.js is the operator CLI and limiter-move.js moves tokens into their mint limiters with the daemon stopped |
web/ |
Static web front-end (no framework, no external dependencies), served by the daemon |
watcher/ |
compages-watch.js, the independent checker (see "The watcher"), with lib/checks.js and unit tests |
reserves/ |
The signed reserve snapshots (see "Signed reserve snapshots"): snapshot.mjs takes one, verify.mjs checks them, keygen.mjs creates the attestation key; lib/format.mjs is the format itself (canonical JSON, hashing, signature and chain checks), with unit tests and an end-to-end test against stand-in nodes |
e2e/ |
Full-stack end-to-end tests: run-e2e.sh (anvil + a mock Solana RPC + Sequentia elementsregtest + the real daemon and contracts) and run-leaf-e2e.sh (deposits paid as Arca leaves, on an anchored proof-of-stake regtest chain with an Arca operator; leaf-chain.mjs produces its blocks) and run-issuer-e2e.sh (an asset an Arca operator serves issuer-operated, checked by the daemon, the watcher through mini-esplora.mjs, and the snapshot tool) |
docs/ |
Runbooks: mint-limiter.md, leaf-deposits.md, and the bridged USDC standard |
contrib/ |
handover-rehearsal.sh, the Circle hand-off rehearsal on a Sepolia fork (see "Rehearsing the hand-off to Circle"); safe-owner.md, the runbook for moving the vault owner to a Safe, and safe-owner-rehearsal.sh, its rehearsal on a Sepolia fork |
The daemon's only runtime dependency is ethers.
Contract tests: unit tests for deposits and deposit rules, roles and access control, the rate limit and queue, owed payouts and claims against rejecting, blocklisting, pausing and non-standard tokens, the stablecoin hand-off and the deploy script; CCTP tests against mocks that follow Circle's V2 message layout, pinned byte for byte to a real Sepolia message; OFT receiver tests against mocks of LayerZero's EndpointV2 compose queue and an OFT adapter, pinned byte for byte to a real USDT0 compose message from Ethereum; and an invariant suite that drives random sequences of every operation and checks that the vault's balance always equals what its events credited in less what they paid out, that it always covers everything owed, queued or cancelled, and that no outflow ever spends that reserved escrow:
cd contracts
forge test
Daemon unit tests, which need no network: the Solana CCTP V2 encoders and
parsers, checked against fixtures produced by @solana/web3.js and Anchor
over Circle's program IDLs, real attestation-service responses and a devnet
simulation; the record state machines (what a paid, owed, queued, cancelled,
discarded or CCTP payout does to a redemption or refund, and how a payout
the vault already took on is read back from its queue and events); the
Bitcoin-anchor finality gate; an operator's retry and resolve decisions;
deposits paid as leaves (receive requests, the landing check and its
fallbacks, and a board or payment whose answer was lost, found in the wallet
rather than made twice, against a stand-in wallet);
per-client rate limits, including IPv6 /64 grouping and trusted proxies; the
redaction of RPC URLs from API errors; alert cooldowns; and the mint
limiter's window rule, as the covenant computes it:
cd daemon
npm ci
npm test
Watcher unit tests: rebuilding a vault's books from its events, backing net of owed, queued and cancelled reservations, how long a reserve gap must last before it is a breach, what the brake pauses and halts, the leaf route's boards and reconciliation, and the mint limiter checks, with the vendored descriptor reproducing every golden vector:
cd watcher
npm ci
npm test
Reserve snapshot tests (canonical JSON and its agreement with jq, signing
and verification, the hash chain and every way to break it, write-once
storage, the auditor's checkpoint handling, and the whole snapshot and verify
cycle against stand-in Sequentia, Ethereum and Solana nodes and daemon):
cd reserves
npm ci
npm test
With AUDIT_SCRIPT pointing at the node repository's
contrib/asset-supply-audit/audit.py, they also run the real auditor against
a stand-in chain, through a checkpoint resume, a reorg and a blinded
reissuance.
Full end-to-end test:
e2e/run-e2e.sh
Brings up anvil, a mock Solana RPC (an in-memory ledger that independently
decodes and signature-checks every submitted transaction), deploys the vault
and a mock ERC-20, starts a Sequentia elementsregtest node and the daemon,
then drives the full lifecycle: first-bridge issuance, duplicate-free
reissuance, native ether bridging, redemption with exact release and supply
destruction, automatic refund of an undeliverable deposit, the Solana leg
(wrap, reissue, sweep, unwrap, and the cross-leg wrong-network guards),
fee-asset independence (the bridge wallet never touches the policy asset),
the unified-asset ceremony (USDC from Ethereum and from Solana landing on one
USDC.e), registry metadata binding, fault injection, and the mint limiter.
For the faults, the daemon reaches the node through e2e/fault-proxy.mjs,
which makes the node go silent right after a mint or a delivery is broadcast,
drops the answer to a burn the node accepted, and the suite kills and
restarts the daemon mid-mint. After each, the user must hold exactly what
they deposited and the supply the chain reports must equal the daemon's
ledger. Every mint is a spend of the asset's mint limiter, on a node started
with Simplicity active: the suite checks that every token sits whole in its
limiter and none in the wallet, that a deposit over the window's allowance
waits for the next window, that the brake applied with a signature the cold
key made in advance stops minting, and that a release of the brake the
daemon did not make is followed and the waiting deposit then mints. Requires
foundry, node >= 20, the daemon's dependencies (npm ci in daemon/),
mint-limiter from a sequentia-contracts checkout (MINT_LIMITER names the
binary; by default $CONTRACTS_REPO/tools/mint-limiter/target/release/mint-limiter,
with CONTRACTS_REPO defaulting to ~/sequentia-contracts) and the Sequentia node
(sequentiad/sequentia-cli): either a release from the
download page, with
SEQ_BIN_DIR set to the bin/ directory of the unpacked tarball, or a build
of the Sequentia repo, with
SEQ_REPO set to the checkout (the script looks in build-linux/src, then
src). The registry checks are skipped unless REGISTRY_REPO points at a
checkout of sequentia-registry. Every port it uses can be moved with an
environment variable of the same name (ANVIL_PORT, SEQ_RPC, SEQ_P2P,
API_PORT, SOL_PORT, FAULT_PORT, IRIS_PORT, REGISTRY_PORT). The suite
takes about a quarter of an hour.
SEQ_BIN_DIR=~/sequentia-core-<version>/bin e2e/run-e2e.sh
Deposits paid as Arca leaves have a suite of their own:
SEQUENTIAD_EXEC=~/sequentia-core-<version>/bin/sequentiad ARCA_BIN_DIR=<dir holding arca, arcad, arca-signer> \
PG_BIN_DIR=/usr/lib/postgresql/16/bin MINT_LIMITER=<mint-limiter> e2e/run-leaf-e2e.sh
It brings up anvil and the vault, a Sequentia proof-of-stake regtest chain
anchored to a Bitcoin regtest parent (its blocks certified by the test
committee, so an Arca operator's finality holds), PostgreSQL, an Arca
operator (arca-signer and arcad from
ConcatenaLabs/arca), the bridge's
leaf wallet and a depositor's arca wallet (the three binaries from one
revision of arca, one with landing-1 leaves; the workflow pins it), then
checks that: an asset
bridged on the chain is served once the operator reloads; requests for
another operator or chain, naming an amount or another asset, or for a token
not bridged are refused before any deposit; a deposit for the depositor's
fresh request arrives in that wallet as a leaf of the deposit's credit less
the transfer's margins, through the daemon killed once with the mint not
boarded and once with the board in flight, the reserves reconciling exactly
before, during and after; the daemon killed right after the wallet paid and
before it recorded the payment finds the payment on restart and pays nothing
twice; a second deposit to a used request is refunded on Ethereum; and a
request that has lapsed when its deposit lands is delivered on the chain to
the intent's fallback address. It also issues a supervised stablecoin
through the unified-asset ceremony, has the operator serve it under the
supervised profile (its signer reading freezes from the node), refuses a
request whose landing was stripped, and checks that a deposit arrives as a
landing-1 leaf landing at the depositor's own landing key, held by the
bridge at its own landing in between. With AUDIT_SCRIPT set to the node
repository's contrib/asset-supply-audit/audit.py it also takes a reserve
snapshot (a dry run) and checks its leaf figures. Every port is chosen free;
the run lives in e2e/run-leaf and takes about three minutes.
An asset an Arca operator serves issuer-operated has a suite of its own:
SEQUENTIAD_EXEC=~/sequentia-core-<version>/bin/sequentiad ARCA_BIN_DIR=<dir holding arca, arcad, arca-signer> \
PG_BIN_DIR=/usr/lib/postgresql/16/bin MINT_LIMITER=<mint-limiter with sign> \
AUDIT_SCRIPT=<Sequentia checkout>/contrib/asset-supply-audit/audit.py e2e/run-issuer-e2e.sh
It brings up the same chains and operator, the watcher (reading
e2e/mini-esplora.mjs, a small stand-in for the indexer over the node's RPC)
and the snapshot tool, and serves the bridged asset issuer-operated, the
operator's signer minting through the asset's mint limiter beside the daemon.
It checks that a refresh minting into a round passes every reserve check
(the daemon follows its limiter through the round, its invariants and
/api/por, the watcher and a signed snapshot taken after the round, which
verify.mjs --rederive re-reads); that the batch's sweep burns it, the
abandoned leaf with it, and the accounting follows; that a snapshot at a
height before the sweep, taken after it, still counts the batch output,
naming its spender; that a reorg across the round and its sweep keeps the
asset backed while both are out and once both are back; and that a limiter
mint to a coin of the minter's own halts the asset in the daemon, trips its
supply invariant and makes the watcher halt it too. The run lives in
e2e/run-issuer and takes about ten minutes.
GitHub Actions runs all of the above on every pull request and every push to
main (.github/workflows/ci.yml): forge build and forge test, the
daemon, watcher and reserve snapshot unit tests on Node 22, and the
end-to-end suite against the Sequentia node release named in the workflow,
whose tarball it checks against a pinned SHA-256, with mint-limiter built
from the sequentia-contracts commit the workflow pins. No job needs a secret; the
Sepolia fork rehearsals and the real-auditor reserve test are not run there.
The keys in the e2e script are anvil's standard, publicly known development keys; they hold nothing on any real network.
Under Circle's
Bridged USDC Standard
the issuer can adopt USDC.e in place: the supply is locked, Circle takes the
minting power on Sequentia and burns the Ethereum escrow, and the asset becomes
a direct liability of Circle without any balance moving. The Ethereum half of
that can be rehearsed against the vault and USDC exactly as deployed on
Sepolia:
contrib/handover-rehearsal.sh
It runs contracts/test/fork/HandoverRehearsal.t.sol on a local fork of
Sepolia (Foundry required; the RPC defaults to Tenderly's public gateway and
is overridden with REHEARSAL_RPC_URL). Nothing is broadcast: the vault's
owner, guardian and operator, and USDC's masterMinter and blacklister
(read from the token proxy), are impersonated on the fork. FORK_BLOCK pins
the fork to one block for a reproducible run, CIRCLE_BURNER names the
burner address Circle supplies, VAULT and USDC point it elsewhere, and
further arguments go to forge test (-vvvv for call traces). A plain
forge test skips it.
It walks the hand-off in order and prints each step, whether it was accepted or refused, and the escrow before and after:
- In flight. Four redemptions are queued: one is executed, one is cancelled by the guardian, one stays queued, and one pays a blacklisted recipient and becomes owed. These are the three reservations the burn must spare.
- Supply lock. The guardian pauses deposits, the in-flight releases
settle, then the guardian pauses releases. Deposits, releases, queued
executions and
rebalanceOutare then refused. - Burner. The owner calls
setStablecoinBurner(usdc, burner). The burn still fails at this point, because FiatToken lets only a minter burn. - Minter. USDC's
masterMintercallsconfigureMinter(vault, 0): the vault can burn and cannot mint. - Burn. Every other role is refused
burnLockedUSDC(); the burner's call emitsLockedStablecoinBurned, and a second call finds nothing left.
It passes only if USDC's totalSupply falls by exactly the vault's
unreserved balance, the vault is left holding exactly its owed, queued and
cancelled amounts, both pauses still hold afterwards, and an owed amount can
still be claimed while releases stay paused. A vault holding under 1 USDC is
first topped up with 10 USDC through a temporary minter, so there is always
something to burn.
The Sequentia half is performed with the node's RPCs (sequentia-cli), in
this order, once the supply lock is in place and reconciled:
- Minting power.
listissuances <asset>gives the asset's reissuance token id (token). The whole token supply, 1, goes to Circle's address withsendtoaddressnamingassetlabel=<token>(and the fee asset infee_asset_label). Holding it is the only way to mint the asset. - Supervision keys. Both rotations are signed by the current recovery
key, so the operational key is rotated first and the recovery key last.
getsupervisionrecordhash rotateoperational <asset> <new key> <old key> <txid> <vout>gives the message to sign (BIP340, offline), where<txid> <vout>is the first input the record's transaction will spend;buildsupervisionrecordturns the signature into a record script,addsupervisionrecordoutputappends it to acreaterawtransactionthat spends that input, andsignrawtransactionwithwalletandsendrawtransactionpublish it. Then the same withrotaterecovery.getsupervisedassetsshows the asset's current keys, anddoc/sequentia/supervised-assets.mdin the node repository covers the records. - Registry identity. The asset registry's
POST /succeedtakes{ asset_id, contract, signature }: the new contract (Circle's name, ticker and domain, at the same precision) signed by the currentissuer_pubkey, the key pinned asunifiedIssuerPubkey, withtools/sign-succession.jsfromsequentia-registry. Circle's domain serves the usual proof.
The full sequence, including the reconciliation that makes the escrow equal
the circulating supply, is in
docs/bridged-usdc-standard.md.
- Centralized custody. The vault's keys control it. Its owner can be a multisig and its hot operator is rate-limited, but there is no threshold scheme over releases and no fraud proofs. Do not use this design to hold funds of value.
- Testnet only. Sepolia, Bitcoin testnet4, the Solana devnet and the Sequentia public testnet; all tokens are worthless.
- Single hot key and single process. The operator keys (Ethereum,
Solana, and the mint limiter's operator key) sit on the bridge host; state
is one JSON file (
daemon/lib/state.js), fine for a PoC, not for volume. The vault's rate limit and the mint limiter bound what a leaked key can move; they do not make it harmless. - A first issuance is not limited. The first deposit of a new token issues a new asset for the deposit's amount, and only then does its token move into a limiter. A hot key can therefore issue new assets, never more of an existing one.
- Mints are at least two blocks apart per asset. The covenant requires it, so after any mint there is a block in which only the brake can spend the coin; deposits of one asset arriving faster queue behind each other.
- Exotic token-2022 extensions are handled honestly but not specially. Transfer-fee mints bridge and release at the actually-received amounts (detection reads balance deltas, not instruction amounts); transfer-hook or non-transferable mints may leave a deposit unsweepable or a release unexecutable, in which case the record parks for the operator instead of looping.
- Unauthenticated intents. Anyone can create redemption intents; each one allocates a wallet address. Harmless at PoC scale, a griefing surface at real scale.
- The state file is the Solana leg's replay guard. The Ethereum leg
reconciles against the vault's on-chain
processedRedemptionsafter any state loss; the Solana leg has no contract, sostate.jsonis what stops double-mints and double-releases there. Treat it like a wallet: keep it on durable storage, and never restore an old copy while the daemon can act. - Redemptions are slow by design on the live deployment: 100 Bitcoin-anchor confirmations, because Bitcoin testnet4 allows deep reorgs.
Compages is one component of the Sequentia testnet ecosystem. The umbrella
protocol documentation lives in
Sequentia/doc/sequentia/.
| Repo | One-liner |
|---|---|
Sequentia |
The Sequentia node (Sequentia Core, sequentiad; a fork of Elements 23.3.3): consensus, anchoring, proof of stake, open fee market, plus the canonical protocol documentation in doc/sequentia/. |
sequentia-registry |
Sequentia Asset Registry service (asset metadata). |
sequentia-explorer |
Sequentia block explorer frontend (esplora fork); the indexer lives in sequentia-electrs. |
SWK |
Sequentia Wallet Kit: a fork of Blockstream LWK; Rust wallet library, CLI, and WASM bindings for building Sequentia (and Bitcoin testnet4) wallets. |
seqdex |
SeqDEX: non-custodial atomic-swap DEX; P2P order book (seqob), same-chain swaps, and cross-chain BTC↔asset swaps made safe by Bitcoin anchoring. |
Development happens on main; open pull requests against it. Before
committing, run forge test and, for daemon changes, npm test in daemon/
and e2e/run-e2e.sh; for watcher changes, npm test in watcher/. CI runs
the same checks on the pull request.
Never commit config.json, operator.key, or state files (they are
.gitignored; keep it that way).
MIT, see LICENSE. The Solidity sources carry matching SPDX
identifiers.