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 and mint/burn 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.
- 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
bridged-usdc-standard.md
in the node repository.
| 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.
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).
- 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".
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) |
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); 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 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) |
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>
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. - 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. - 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. - 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:[email protected]:<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. --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/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 |
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 test: anvil + a mock Solana RPC + Sequentia elementsregtest + the real daemon and contracts |
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;
per-client rate limits, including IPv6 /64 grouping and trusted proxies; the
redaction of RPC URLs from API errors; and alert cooldowns:
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, and what the brake pauses and halts:
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, and fault injection: the daemon reaches
the node through e2e/fault-proxy.mjs, which makes the node go silent right
after a reissuance 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. Requires foundry, node >= 20, the
daemon's dependencies (npm ci in daemon/) 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. The suite takes about a quarter of an hour.
SEQ_BIN_DIR=~/sequentia-core-<version>/bin e2e/run-e2e.sh
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. 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
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) sit on the bridge host; state is one JSON file
(
daemon/lib/state.js), fine for a PoC, not for volume. - 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.