Live on the shielded chain since phase S3. A bridged holding is a note, not a balance.
BridgeStatehas no per-account balances at all — the S1 pool deleted the accounts they were keyed by — so what a wallet "has" in a bridged asset is the notes of that asset its viewing key opens, exactly as for RAND. A note'sassetword is the registry's denseu32index for the asset; index 0 is RAND. There is norand_getAssetBalanceand there never will be again.§§1–3 (the trust model, the wire format, guardian sets) are unchanged by any of that and are the reference the shielded half is built on. §§4–9 describe the bridge as it stands now. §10 is what changed when the pool replaced the accounts, for anyone holding an account-era integration against it.
This page assumes docs/architecture.md and docs/shielded.md. It covers the wire format, the
guardian-attestation model, the on-chain BridgeState, the two bridge actions, and the
storage/RPC/wallet surface built on them.
source chain (Ethereum / BSC / Tron / Solana) Rand (RAND)
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ token contract / program │ lock + emit Transfer │ BridgeAttest transaction │
│ locks tokens, emits │ ───────────────────────────────►│ admission: size cap → time │
│ (amount, token, to, fee) │ guardians observe, │ → decode → replay → │
└──────────────────────────────┘ sign mu = keccak²(body) │ emitter → payload → sigs │
▲ threshold n·2/3 + 1 │ effect: one deposit note, │
│ │ computed by the chain │
release │ ┌──────────────────┐ attestation │ │
after guardian │ │ guardian committee│ ◄──────────────────┤ │
signatures on │ │ secp256k1 keys, │ │ BridgeBurn transaction │
the burn │ │ rotated by the │ guardians read │ one bundle: slots 0–1 burn │
message │ │ governance emitter│ the burn log │ the asset, 2–3 pay the RAND│
└────────┤ (Rand, sole) │ ◄──────────────────┤ fee. Appends a │
└──────────────────┘ sequence │ BridgeBurnRecord │
└──────────────────────────────┘
Inbound: value is locked on the source chain, guardians attest, anyone submits the attestation to Rand, and the ledger appends one deposit note for the recipient. Outbound: a Rand transaction burns notes and emits a message, guardians attest that message, the source-chain contract releases. Rand never verifies a source-chain header or state proof; the guardian committee's signatures are the entire trust model.
The bridge moves value between Rand and four other chains, by a bridge-local chain id
(crates/bridge-codec/src/lib.rs):
| Chain | Id |
|---|---|
| Rand | 1 |
| Ethereum | 2 |
| BSC | 3 |
| Tron | 4 |
| Solana | 5 |
Three of the four are EVM-family; Solana is not. The design is Wormhole-shaped: the same guardian-signed attestation format is shared by Solidity contracts, a Solana program, and this fullnode. It is a guardian-attestation bridge, not a light-client or on-chain proof bridge — no source-chain header or state proof is ever verified on Rand.
An attestation is a guardian-signed envelope: a body (metadata plus an opaque payload) and a
threshold of ECDSA signatures over the body's digest. Guardians are a fixed committee of
secp256k1 keys; a guardian's on-chain identity is the last 20 bytes of
keccak256(uncompressed_pubkey[1..]) (crates/randprotocol-core/src/bridge/mod.rs) — Ethereum's
address derivation, reused so the same key/address works across the EVM contracts, the Solana
program, and Rand.
Quorum for a set of size n is n*2/3 + 1 (quorum, crates/bridge-codec/src/lib.rs). Each
signature carries r, s, and recovery id v, and must be low-s: s <= n/2 for the
secp256k1 curve order, pinned as SECP256K1_HALF_N — a high-s signature is rejected outright,
foreclosing signature malleability. The digest guardians sign is mu = keccak256(keccak256(body_bytes)), a double keccak (crates/randprotocol-core/src/bridge/mod.rs).
A governance emitter is a fixed, non-guardian address Rand treats as the sole authorized
source of guardian-set rotations: keccak256("rand-bridge-governance"), pinned as a literal
(GOVERNANCE_EMITTER, bridge-codec/src/lib.rs) and cross-checked by a test
(governance_emitter_matches_string). It is a slot in the (emitter_chain, emitter_address) pair
that only a GuardianSetUpgrade payload may claim, and only as (CHAIN_RAND, GOVERNANCE_EMITTER).
The redirect attack, and why it is closed (2026-09-19, Task 5b, chain 14). A Rand transaction
carries no signature: a burn is authorised by the STARK proof of its bundle (two bundles before
chain 14's hidden-asset bundle), which shows the burner held the notes. Until the transaction
binding, a proof committed only to its bundle's own digest (anchor, nullifiers, commitments, fee,
burn fields, time) — nothing tied them to the
BridgeBurn action around them. Anyone who saw a pending burn in gossip (a peer, a proposer) could
copy it, keep both proofs byte for byte, swap to for their own address (or raise relayer_fee
and relay it themselves, or name another backing's (to_chain, token)), and race it; whichever
copy committed first spent the notes, and the source-chain release contract would then pay the
attacker — theft, with nothing on Rand to show it was not the burner's intent. An attest could be
copied the same way with its deposit r, time or envelope changed (stranding the recipient's
note), or its fee bundle lifted onto another transaction. Now every bundle proof is made over, and
verified against, the eight words of Transaction::binding — a hash of the whole transaction
(chain id, the one bundle including its four envelopes, the action) with only the bundle proof
bytes blanked —
as its public input segment, so any copy that changes any field fails the proof
(TxError::InvalidBundleProof, PublicValues); the ledger tests named in docs/confidential.md
admitted each of these copies before the fix and refuse them after. Every wallet and relayer
must run the new binary at the fork: an old rand makes empty-segment proofs, which the chain
now refuses.
What this does not protect against. The trust model is entirely the guardian committee's
signatures — no fallback or dispute mechanism, no independent light client. If n*2/3 + 1
guardians collude they can mint arbitrary value or authorize an illegitimate rotation; nothing
here detects or resists that. The one deliberate hardening against a partially compromised
committee is the rotation rule in §3.
crates/bridge-codec is #![no_std] + alloc, zero dependencies — the single source of
truth for the byte layout shared by the Solidity contracts, the Solana program, and this
fullnode. Its own doc comment: "hashing and ECDSA are deliberately NOT performed here; callers
inject a keccak256 and a secp256k1 verifier" — kept out of the codec so it stays portable to
whatever verifies it on the other chains; randprotocol-core supplies both (sha3, k256).
Every integer is big-endian. Wire version is VERSION = 1 (bridge-codec/src/lib.rs);
Attestation::decode rejects any other value as CodecError::BadVersion.
Signature — 66 bytes (crates/bridge-codec/src/envelope.rs)
| Offset | Len | Field |
|---|---|---|
| 0 | 1 | index |
| 1 | 32 | r |
| 33 | 32 | s |
| 65 | 1 | v (recovery id) |
Body header — 51 bytes, then the payload (envelope.rs)
| Offset | Len | Field |
|---|---|---|
| 0 | 4 | timestamp |
| 4 | 4 | nonce |
| 8 | 2 | emitter_chain |
| 10 | 32 | emitter_address |
| 42 | 8 | sequence |
| 50 | 1 | consistency_level |
| 51 | .. | payload |
Attestation::body_bytes returns this slice straight out of the original wire bytes, unre-encoded
— "exactly what gets hashed to produce mu" — so the digest never depends on a future re-encoder
matching today's byte-for-byte.
Attestation envelope (envelope.rs)
| Offset | Len | Field |
|---|---|---|
| 0 | 1 | version (= VERSION) |
| 1 | 4 | guardian_set_index |
| 5 | 1 | n_sigs |
| 6 | 66*n_sigs |
signatures[] |
6+66*n_sigs |
.. | body |
Transfer payload — id 1, fixed 133 bytes (crates/bridge-codec/src/payload.rs)
| Offset | Len | Field |
|---|---|---|
| 0 | 1 | id = 1 |
| 1 | 32 | amount (u256 BE) |
| 33 | 32 | token_address |
| 65 | 2 | token_chain |
| 67 | 32 | to |
| 99 | 2 | to_chain |
| 101 | 32 | fee (u256 BE) |
TRANSFER_PAYLOAD_LEN = 133; any other length is CodecError::BadPayloadLength. amount/fee
are full u256 but Rand only unpacks the low 128 bits (amount_u128/fee_u128); a non-zero top 16
bytes yields None, mapped upstream to BridgeError::AmountOverflow.
GuardianSetUpgrade payload — id 2, variable length (payload.rs)
| Offset | Len | Field |
|---|---|---|
| 0 | 1 | id = 2 |
| 1 | 4 | new_index |
| 5 | 1 | n (guardian count) |
| 6 | 20*n |
keys[] |
Decode rejects n == 0 (ZeroGuardians), too few bytes (Truncated), or too many
(TrailingBytes). The codec does not check new_index == current + 1, uniqueness, or non-zero
keys — that is randprotocol-core's job; the codec has no notion of "current guardian set."
Left-padding. to, token_address, and emitter_address are fixed 32-byte slots, so the wire
format is one chain-agnostic shape regardless of native address width. Ethereum/BSC/Tron addresses
are 20 bytes, left-padded with 12 zero bytes. bridge-codec doesn't enforce this — it's chain-shape
policy, not wire law — but BridgeState::check_burn does on the way out
(crates/randprotocol-core/src/bridge/state.rs): non-zero to[..12] is BadRecipient on chains 2/3/4,
and an all-zero to is BadRecipient everywhere. The wallet no longer restates those rules (the
account era's check_burn_recipient went with the accounts): wallet::submit_burn pre-checks only
the two things it would otherwise throw two bundle proofs away on — a zero amount and a relayer fee
larger than the amount — and leaves the rest to admission (§8).
BridgeConfig (crates/randprotocol-core/src/bridge/state.rs) carries the genesis emitter address,
initial guardian keys, and per-source-chain emitter table, as human-readable hex JSON.
BridgeState::from_config installs those keys as guardian set index 0, current_set = 0,
expires_at: 0 — "never expires."
Rotation is a GuardianSetUpgrade attestation whose emitter must be (CHAIN_RAND, GOVERNANCE_EMITTER) exactly, else WrongEmitter. BridgeState::check_attest then enforces:
new_index == current_set + 1— no skipping — elseBadUpgradeIndex { expected, got }.- New keys non-empty (codec
ZeroGuardians), unique, non-zero — elseDuplicateGuardian. - The attestation's
guardian_set_indexmust equalcurrent_set, not merely some still-unexpired set (c84c9f0). Without this, a set just rotated away from — e.g. because it was considered compromised — could use its own grace window to sign another rotation and re-seize the bridge for up to a day. This brings Rand to parity with the existing Solana program and EVM contracts.
Applying an upgrade (apply_attest) sets the old current set's expires_at = now + GUARDIAN_GRACE_SECS (= 86_400 seconds, bridge-codec/src/lib.rs) so in-flight transfers
signed by the outgoing set stay valid for a day, installs the new set with expires_at: 0, and
advances current_set.
Expiry is evaluated against now, block time (timestamp_ms / 1000), not wall clock — checked
only on a bridge-configured chain. Commit f6e5639: apply_block rejects a bridged chain's block
whose timestamp precedes its parent's (BlockError::TimestampRewind, gated on
self.bridge.is_some()), and HotStuff::propose emits max(now_ms, parent.timestamp_ms) so a
lagging leader never proposes a block its peers must reject. Every ledger built from a head block
carries that block's timestamp (apply_block, HotStuff::resume/propose, storage replay), so
expiry is never evaluated at now = 0. Every path that accepts a block goes through apply_block
— HotStuff::on_proposal (where the refusal surfaces as ConsensusError::Execution, like any
other block rule), the node's catch-up sync, and --verify-chain's replay — so none of them can
drift from the rule. Regression test:
a_bridged_chain_refuses_a_block_whose_timestamp_rewinds_its_parents
(crates/randprotocol-core/src/ledger/mod.rs), which also pins that an unbridged chain accepts the very
same rewind.
Test coverage: guardian_upgrade_must_be_signed_by_the_current_set,
guardian_upgrade_rotates_with_grace_and_rejects_skips,
upgrade_rejects_duplicate_and_zero_guardian_keys (crates/randprotocol-core/src/bridge/state.rs).
crates/randprotocol-core/src/bridge/state.rs:
| Field | Type | Meaning |
|---|---|---|
emitter |
[u8; 32] |
Rand's outbound emitter address, stamped into burn messages |
emitters |
BTreeMap<u16, [u8; 32]> |
registered emitter per source chain |
guardian_sets |
BTreeMap<u32, GuardianSet> |
every set ever seen, by index |
current_set |
u32 |
index of the authoritative ECDSA set |
spent |
BTreeSet<Hash> |
consumed attestation digests (inbound replay guard) |
burn_sequence |
u64 |
next outbound sequence number |
burns |
BTreeMap<u64, BridgeBurnRecord> |
every outbound message, held whole in memory |
pq_guardians |
Vec<PublicKey> |
B3: the genesis Dilithium2 set, index-aligned with the ECDSA guardian_sets[0]; fixed for the chain's life |
pause_key |
Option<PublicKey> |
B1: the one Dilithium2 key that may PauseMints |
mint_paused |
bool |
B1: while true, every transfer BridgeAttest is refused |
pause_nonce |
u64 |
B1: what the next PauseMints/UnpauseMints must carry |
list_nonce |
u64 |
B4: what the next ListBacking/RegisterBridgedToken must carry |
There are no balances. Phase S3 deleted balances: BTreeMap<(AssetId, Address), u128> outright:
a bridged holding is a note in the ledger's own commitment tree, and the tree is what commits to it.
What is left here is the public half of the bridge — who may attest, which digests are consumed,
what has been burned outbound, and (since bridge hardening) the pause and listing state.
AssetId = blake3("rand-bridge-asset" || token_chain BE u16 || token_address) — a pure,
domain-separated function of (chain, address), computable even without a bridge (§6); it is still
what rand_bridgeAssetId computes and what a Transfer payload's (token_chain, token_address)
resolves to, but it is no longer a registry key: see §13.
Asset indices moved to the RPL token registry (v0.5). BridgeState used to own the map from
AssetId to { chain, token, index } and the counter that assigned the next index; the RPL token
standard (docs/tokens.md) replaced both with crate::ledger::tokens::TokenRegistry, a
ledger-level registry shared by native and bridged tokens alike. BridgeState::check_attest/
check_burn now resolve an asset through the registry instead of owning it; §13 has the details.
A note's asset field is still one dense u32 word (FIRST_TOKEN_INDEX = 1, 0 is RAND), and
bridged amounts must still fit a note's u64 (BridgeError::AmountOverflow above that).
blake3("rand-bridge-state-4"
|| bincode(emitter, emitters, current_set, guardian_sets)
|| merkle(sorted spent digests)
|| burn_sequence BE
|| bincode(pq_guardians)
|| bincode(pause_key, mint_paused, pause_nonce, list_nonce))
emitter/emitters are included because they are consensus-relevant genesis configuration, not
incidental metadata. burns is excluded — derivable from transaction history; test
root_changes_with_the_spent_set_and_the_sequence_but_not_burn_records clears burns on a clone
and confirms the root is unchanged. The asset registry and its next_index counter are not here
at all — both moved to TokenRegistry, which the chain's state root commits to separately as
tokens_root (§13); committing them here too would only give two places for the same fact to
disagree.
The root is pinned by root_is_pinned_for_a_fixed_state: a fixed state (two guardians, two PQ
guardians, a pause key, one spent digest, burn_sequence = 7) must hash to
4f2ea1290d513a855ddaaa7aa2ce68a3bd5a54ef3f84819b238a6e9c1da0f712. The test's comment: "Changing
this hash changes consensus ... treat a failure here as a hard fork, never as a test to
re-baseline." It has been re-pinned four times: in S3 (rand-bridge-state, unnamed domain →
implicit; balance leaves left, the registry leaf gained an index — account-era value
c757e13d…b043, S3 value ee50b48c…0c43dc8), when the RPL token standard moved the registry
and next_index out to TokenRegistry (domain rand-bridge-state-2, value before B3
89555202…52f9ba642cf5b), when B3 appended the PQ guardian set (domain rand-bridge-state-3,
value before B1/B4 2504a9da…21b5aceb6366e141), and when B1/B4 appended the pause key, the pause
flag and the two governance nonces (domain rand-bridge-state-4, value before that
2f1798b1…690499dd6550e63c46).
The bridge root folds into the chain's state root only when a bridge exists:
state_root = blake3("rand-state-2" || tree_root || nullifier_root || validators_root || programs_root)
= blake3("rand-state-2" || … || programs_root || bridge_root) with a `bridge` section
A bridge-less chain commits exactly the 128 bytes phase S1 committed — byte-identical, no fifth
word — verified by the_bridge_root_is_appended_only_on_a_bridged_chain. The genesis hash
follows the same rule: it appends bincode(BridgeCommit) only when Genesis.bridge is Some.
BridgeCommit is a plain-bytes twin of BridgeConfig — the config's serde is human-readable hex,
and hashing that would commit to hex strings, not the bytes the chain runs on.
BridgeMeta is the "whole-state half": everything in the table except spent and burns, which
storage keeps one row per key. meta()/from_parts() destructure the struct field by field on
purpose, so a new field must be classified as meta or column family or the build breaks.
Genesis::build accepts a bridge section again (S1 rejected one outright) and validates it
(check_bridge, crates/randprotocol-core/src/genesis.rs): it rejects an empty guardian set, a duplicate
or zero-value guardian key, a zero emitter, an emitter equal to the governance emitter, CHAIN_RAND
as a source emitter, a source emitter equal to the governance emitter, and a zero-value source
emitter. A genesis registers no assets: a registry starts empty and the first attestation to name
a token is what puts it in, under index 1.
Two Action variants (crates/randprotocol-core/src/types/transaction.rs), bincode tags 7 and 8, after
the three staking actions:
Action::BridgeAttest { attestation: Vec<u8>, recipient: ShieldedAddress, r: Word8, time: u32,
asset: u32, envelope: Envelope }
Action::BridgeBurn { asset: u32, amount: u64, relayer_fee: u64, to_chain: u16,
token: [u8; 32], to: [u8; 32] }
Both are carried by an ordinary shielded transaction, and both pay their fee in RAND: an attest
pays BUNDLE_BASE (one bundle) and nothing more, because its relayer is paying for a depositor who
holds no RAND yet; a burn pays BRIDGE_BURN_FEE (0.01 RAND), the bridge's charge towards the
validators' infrastructure, which also covers the base of its one bundle. On a chain without aggregation the whole fee is the block proposer's. See docs/confidential.md's fee table.
-
The recipient is a hash on the wire. The 32-byte
tofield of theTransferpayload isblake3("rand-shielded-recipient", pk || kem_ek)of the recipient's shielded address (ShieldedAddress::recipient_hash): a shielded address is about 1.2 KB and the wire format has room for a hash. The source-chain depositor names the hash, the guardians sign it, the submitter puts the full address in the action, and the ledger recomputes the hash and rejects a mismatch (TxError::BridgeRecipientMismatch). Without that equality the submitter would choose who receives someone else's deposit. -
The chain computes the note, not the submitter. The deposit note is
{ pk: recipient.pk, from: 0, amount, asset: <registry index>, time, r }and its commitment isbridge_notes::deposit_commitment— from the amount the guardians signed, so a relayer cannot inflate a mint or redirect it. It is the one commitment a transaction does not carry on the wire (Transaction::commitmentsomits it), which is why a node indexing notes for wallets recomputes it throughbridge_notes::deposit_note. -
The blinding is derived, not chosen (F1, chain 14).
risblake3("rand-deposit-r-1" || mu)read as eight little-endian words (bridge_notes::derive_deposit_r), overmu = keccak256(keccak256(body))— the 32 bytes the guardian quorum signed. The ledger requires it (BridgeError::WrongDepositBlinding) and refuses anything else before any signature work: two keccaks and a blake3 over the already size-capped attestation bytes, ahead of the PQ structure check and the secp256k1 quorum. It is a byte verdict, so admission caches it (admission::is_permanent). The field stays on the wire — the transaction is self-describing and the wallet's public-field rebuild (§8) reads it there — it is only constrained. The rule applies to every attest, a rotation's included, so no attest carries a field a copier can vary buttime.Why: before it, the deposit note's commitment was not fixed until someone submitted the attestation. Anyone who saw a relayer's transaction could submit the same attestation first under a blinding of its own — the recipient still got a note (the wallet rebuilds it from the public fields), but the relayer's own transaction died on
Bridge(Replay)orCommitmentExistsafter paying for a bundle proof, and the recipient's envelope was replaced. Withrfixed, two submitters of one attestation at onetimename one note, so the pool conflicts them on the commitment claim instead of racing them to two different notes.timeis deliberately not bound. It must stay the submitter's: the envelope is sealed against the commitment before the transaction exists, and no rule can derive atimeevery submitter would agree on — the attestation body's own timestamp is the source chain's clock, and clamping it into this chain's window is a function of the height the transaction is judged at, which is exactly what a depositor cannot predict. The residual: a front-runner may still pick anothertimeinside the window, producing a different note — of the same recipient, amount and asset, which its recipient's wallet finds by the same public-field rebuild — and the two submissions then collide on the attestation digest claim instead. -
timeandassetare the depositor's two predictions. The envelope that lets the recipient open the note is sealed against that commitment before submitting, so the depositor has to be able to compute it — and it cannot predict the height the transaction lands at. So both are fields of the action:timeis held to the window a bundle'stimegets (t <= height,height - t <= TIME_WINDOW, checked before the attestation is even decoded), andassetmust equal the index the token registry has already given(chain, token)— resolved from the wire bytes alone, before any guardian signature is verified — elseTxError::AttestAssetMismatch { expected, actual }. Since v0.5 a token must be listed before its first deposit (docs/tokens.md§5): there is no more first-sighting auto-registration, so an attestation naming a(chain, token)nobody has listed isBridgeError::UnlistedToken, not a registration. -
The relayer fee is not deducted. The
Transferpayload's ownfee, in the bridged asset, is carried for the record and paid to nobody: on a shielded chain the submitter has no identity to pay, so the deposit note carries the gross amount the guardians signed. Netting it would burn the difference forever and what the pool holds would stop matching what the source chain locked. -
A guardian-set rotation deposits nothing, consumes its digest, and binds neither
assetnor the recipient.
Transaction {
chain_id,
bundle: <hidden-asset bundle: slots 0–1 spend the token, slots 2–3 pay the RAND fee;
burn_asset = asset, burn_a = amount, burn_r = 0, fee >= BRIDGE_BURN_FEE>,
action: BridgeBurn { asset, amount, relayer_fee, to_chain, token, to }
}
Since the hidden-asset bundle (chain 14) a burn is one bundle: its private slots 0–1 prove in the
zkVM that the burner owned notes of the asset summing to at least amount, with burn_a the value
leaving the pool, and its RAND slots 2–3 pay the fee. burn_a is exactly amount, because the wire format's relayer_fee
is a portion of the amount (fee <= amount), carved out on the destination chain by the release
contract, which pays amount - fee to to and fee to the relayer and so releases amount in
total. A pool that burned amount + relayer_fee would destroy more than the far side ever releases
and strand the difference in the source-chain contract forever. The bundle goes through the
ordinary admission: four distinct unspent nullifiers, four new commitments, the digest recomputed
and the STARK proof verified against the transaction's binding, so it cannot be copied under a
changed to, relayer_fee or (to_chain, token) (§1, "The redirect attack"). apply_burn records the outbound
message with the transaction hash in the sender slot — a burn is funded by notes, so there is no
sender identity — and the next burn_sequence; guardians read the burn log exactly as before.
Ledger::validate_inner (docs/shielded.md §5, spec §7). What is bridge-specific:
- Size caps first, before a byte is parsed:
attestation.len() <= MAX_ATTESTATION_BYTES(16 KiB,crates/randprotocol-core/src/gas.rs), each of the bundle's four envelopes ≤ 2048 bytes, and the one bundle a burn carries gets the caps every bundle gets. A guardian set is at most 255 keys by wire format, so anything past the cap is malformed by construction and must not buy verification work. - The fee floor at step 3 (
BRIDGE_BURN_FEE) refuses an underpaying burn on a comparison. - At step 7 (the action step, before either bundle's proof at step 9):
BridgeAttest:timewindow → bridge present →check_attest(itself ordered cheap-first: decode, guardian-set resolution, the replay check againstspentby digestmu, the payload decode, the emitter binding, the payload's field checks, and only then set expiry, index/quorum, low-s and one recovery per signature) → theassetcomparison → the recipient hash → the deposit commitment against the tree and the fee bundle.BridgeBurn:asset != 0→burn_asset == asset→burn_a == amount→burn_r == 0(tokens::check_asset_burn) →check_burn(a backing of the asset, recipient shape, release unit,relayer_fee <= amount, the backing'slockedcoversamount).
- At steps 8–9, the bundle's digest and proof, verified against the transaction binding
(
Transaction::binding;docs/confidential.md, "Transaction binding").
apply_tx writes the bundle's four notes and then runs the action, so bridge_notes::apply consumes
the CheckedAttestation that validate produced rather than verifying the guardian quorum a second
time. A transaction that fails at either step leaves no state behind, because the caller applies to
a scratch clone and keeps it only if the whole block succeeded.
Replay protection differs by direction:
- Inbound (
BridgeAttest): digest-based. Every consumedmugoes intoBridgeState.spentbefore payload effects apply; a repeat isBridgeError::Replay. No sequence number — the digest is the dedup key, since the same attestation can arrive via any relayer. The mempool indexes pending digests too (MempoolError::AttestationConflict), so two relayers racing one attestation never both make it into a block. - Outbound (
BridgeBurn): a strictly increasingburn_sequence: u64, on eachBridgeBurnRecord.sequence.
Emitter binding: a Transfer must come from the chain's registered emitter
(self.emitters.get(&emitter_chain) == Some(&emitter_address), else WrongEmitter), and
token_chain must equal the emitting chain (WrongTokenChain). A GuardianSetUpgrade must be
(CHAIN_RAND, GOVERNANCE_EMITTER) exactly, else WrongEmitter. An undecodable payload is a hard
error (BridgeError::BadPayload), never a silent fall-through.
Storage (crates/randprotocol-node/src/storage.rs) — two RocksDB column families plus one meta key:
| Column family | Key | Value |
|---|---|---|
bridge_spent |
digest bytes | empty (a set) |
bridge_burns |
big-endian sequence |
bincode(BridgeBurnRecord) |
meta["bridge_state"] holds bincode(BridgeMeta); its presence is what makes a chain "bridged" on
disk. The account era's bridge_balances family is gone. A per-block commit writes only the rows a
block's bridge transactions touched; load_bridge rebuilds a full BridgeState from the meta blob
plus the two families, and truncate_to (reorg replay) rebuilds them wholesale from the replayed
ledger — a consumed digest does not record which block consumed it, so there is nothing to prune by
height. Startup verification replays the chain and then compares the whole stored Ledger against
the replayed one, which covers the bridge including the burn log the root deliberately leaves out
(verify_chain; verify_chain_replays_a_bridged_chain).
RPC (crates/randprotocol-node/src/rpc.rs, docs/rpc.md) — none of it per-address:
| Method | Params | Result |
|---|---|---|
rand_getBridgeState |
[] |
emitter, emitter table, guardian set, pq_guardians, mint_paused, pause_nonce, list_nonce, pause_key, registration_fee, next_index, burn_sequence, assets (the rand_getAssets rows); {"enabled": false} with no bridge |
rand_getAssets |
[] |
the registry's backing rows, ascending by index: { index, chain, token, asset_id, decimals, locked, mint_cap_per_day, minted_today, mint_day } |
rand_bridgeAssetId |
[token_chain, token_address] |
the asset id; pure arithmetic, answers on any chain |
rand_getBridgeBurn |
[sequence] |
one outbound message (body_hex, digest, tx, height), or null |
The token-level RPC (rand_getTokens, rand_getToken, rand_getTokenSupply) lives in
docs/tokens.md: a bridged token's row carries every one of its backings, not one row per backing.
rand_getTransaction renders a bridge_attest with the recipient, the action's asset, the
asset_index and amount it decodes against the registry, the note's time and blinding r, and
the commitment the chain computed from those fields — every word of the deposit note, which is
what makes the recovery path below possible; a bridge_burn with its asset, amount, relayer fee,
destination and the one bundle's public fields (burn_asset, burn_a). Balances are not among
them — there are none.
Wallet (crates/randprotocol-client, docs/cli.md) — bridge and RPL-registry commands:
| Command | Purpose |
|---|---|
bridge-mint <ATTESTATION> --pq <QUORUM> |
deposit an attestation (hex or @path) with its Dilithium2 co-signature quorum: seal the recipient's envelope, pay with one hidden-asset bundle of this wallet's RAND |
bridge-rotate <ROTATION> --pq <QUORUM> |
submit a guardian-set rotation (payload 2) with the current PQ set's co-signature quorum |
bridge-pause --sig <SIG> |
pause bridge minting with the genesis pause key's signature; bundle-less, fee-less |
bridge-unpause --pq <QUORUM> |
lift a pause with a PQ guardian quorum; bundle-less, fee-less |
bridge-burn <ASSET> <AMOUNT> <TO_CHAIN> <TOKEN> <TO> |
burn a bridged asset outbound: checks the bridge, the backing and its locked amount and release unit first, then proves one bundle that burns the asset and pays the RAND fee |
token register-bridged … --pq <QUORUM> |
register a new bridged token after genesis with its first backing (B4) |
token list-backing … --pq <QUORUM> |
add a backing to an existing bridged token (B4) |
asset-balance [INDEX] |
what this wallet's own notes hold in one bridged asset, or a row per asset |
bridge |
the bridge's public state |
bridge-message <SEQUENCE> |
one outbound message, verbatim, for a guardian to sign |
A wallet needs --to only when depositing to an address other than its own, and it checks the
recipient hash, the asset id and the index against the node before paying for a proof.
wallet::attested_deposit reads the deposit out of the attestation bytes with no state and no
signature work, because the envelope has to be sealed before the transaction exists. Full flag
detail for every command above is docs/cli.md's rand (wallet) table.
Tests. bridge_mint_deposits_a_note_and_a_burn_spends_it
(crates/randprotocol-node/tests/cluster.rs) runs the whole path across two validators on a bridged
genesis: an attestation deposits a note only its recipient's viewing key opens (the relayer who paid
for it holds nothing), both nodes register the token under index 1, the same attestation resubmitted
is refused as already consumed, and a burn leaves the change as a note and an identical
outbound message on both nodes. The unit level is bridge/state.rs (the bridge's own rules),
ledger/bridge_notes.rs (the deposit note, the burn rule, the asset and time bindings),
mempool.rs (racing relayers, stale indices) and storage.rs (the round trip).
crates/randprotocol-core/src/bridge/vectors.json is generated externally by a tools/vectors generator
in a separate "bridge repo" and copied in verbatim: top-level keys guardians,
governance_emitter, rand_emitter, emitters, now, vectors, and 39 vectors — named cases
like transfer_eth_usdt_6dp_ok, upgrade_signed_by_superseded_set, quorum_four, high_s,
replay_eth, stale_governance_set.
One test pins them, at the signature level: shared_vectors_match_verify
(crates/randprotocol-core/src/bridge/mod.rs) re-verifies every vector whose expect is one of ok, no_quorum, index_order, index_out_of_range, bad_signature, high_s, wrong_guardian, set_expired, bad_version against bridge::verify, checking the pinned digest (ok) or the exact VerifyError.
checked == 23 — an exact count, not a lower bound, so a vector that stops matching fails the
build instead of quietly being skipped.
unknown_set is deliberately not checked there: verify takes an already-resolved GuardianSet,
not an index, so "no set at this index" is a set-resolution concern only check_attest can raise.
The account era had a second, ledger-level pass (vectors_ledger_level) that drove the remaining
expect values — wrong_emitter, wrong_to_chain, wrong_token_chain, fee_exceeds_amount,
amount_overflow, bad_payload, replay, unknown_set, set_expired, stale_governance_set —
through a real BridgeAttest against a live Ledger. It went with the account ledger in S1 and has
not been rebuilt on the note pool (§8). Every one of those refusals is covered by hand-written unit
tests in bridge/state.rs and ledger/bridge_notes.rs; what is missing is the cross-chain
agreement that the shared vectors give, so a generator change that alters ledger-level behaviour
would no longer be caught here.
- No relayer is paid. The
Transferpayload carries afeefor whoever relays the attestation, and on this chain nobody collects it: the deposit is minted gross and the submitter pays a RAND bundle fee out of its own notes for the privilege. Relaying is therefore altruistic (or paid out of band) until there is a way to pay an identity-less submitter. - A deposit's envelope is bound to nothing, so the wallet does not depend on it. Anyone may
submit an attestation (the guardians' signatures are the whole authorisation) and admission checks
nothing about the
envelopethe submitter publishes beyond its size — so a hostile relayer can seal garbage, consume the attestation's digest, and leave a note the honest relayer can no longer resubmit. (Since F1 the note is no longer the front-runner's to choose: the blinding is the attestation digest's, so at the sametimeboth submitters name the same leaf — §5. What is left is the envelope, and anothertimein the window.) It locks nothing: every field of a deposit note is public in that one transaction (recipient,amount,asset,time,r), sowallet::scanwalks committed blocks, rebuilds the note of everybridge_attestaddressed to it withrebuilt_deposit, and records it against the leaf whose commitment matches — with no envelope opened. The cost of the attack is therefore oneBUNDLE_BASEto the attacker and one extra block walk to the recipient.scanned_attest_heightis the cursor for that walk, so blocks are read once and a store written before this path existed re-reads from zero the first time. In-circuit envelope validity (spec §14) is not needed for deposits for the same reason: nothing about a deposit note is secret. - The ledger-level vector pass has not been rebuilt on the note pool (§7).
bridge-burnstill learns about some bad arguments from the node, after paying for a proof. The wallet pre-checks that the chain has a bridge, that it holdsASSETin the registry, that the named(TO_CHAIN, TOKEN)backs it, that the backing has at leastAMOUNTlocked, and thatAMOUNTand the relayer fee are whole release units — all off onerand_getBridgeState/rand_getTokensread, before any proving. What is left is the endpoint's own policy on the destination chain, which the wallet cannot see: a rejection there still costs one proof (~100 s).- Equal-to-parent block timestamps are allowed. The bridged-chain check in
apply_blockis<, not<=— it forbids a rewind but not a repeat. The residual, in the code's own words: "a colluding 2/3 of leaders can holdtimestamp_msconstant, which freezes outbound burn timestamps and keeps a superseded guardian set inside its grace window indefinitely." BridgeState.burnsis unbounded and kept whole in memory — "known linear growth, to be drained into storage per block before ~100k burns (spec 6.3)". Storage persists new rows incrementally, but the in-memory map is never pruned and is cloned on every speculative block execution.- A burn costs one proof, about 100 s on a laptop, and roughly 1.43 MB of the 4 MiB default
block limit (
docs/rpc.md's "The transaction on the wire") — two burns fit a block, not more. - No light-client or on-chain verification of source-chain state exists or is planned; the guardian committee's signatures are the entire trust model (§1).
- No bridge-specific rate limiting beyond the fee floors and the 16 KiB
MAX_ATTESTATION_BYTEScap.
Unlike the account era, where the two areas shared only the Ledger and the gas module, the bridge
now rides on the pool's machinery:
- Both bridge actions are carried by a transaction whose one bundle is proved by the pinned
hidden-asset zkVM guest (
docs/confidential.md, "The hidden-asset bundle guest"), so every bridge transaction pays for exactly one STARK verification. The guest is what enforces the burn shape —burn_asset/burn_afor the token,burn_rfor RAND — and balances both groups in one proof, which is why a burn no longer needs a second bundle at all (before chain 14 it did). - Deposit notes are appended to the same commitment tree as every other note and are
indistinguishable from them once appended; they are committed by the tree, not by
bridge_root. - The bridge's own work is still plain CPU cryptography: keccak256, secp256k1 recovery, and set
lookups.
bridge-codecandrandprotocol-core::bridgeimport nothing confidential or zkVM-related. - The privacy of a bridged holding is the privacy of a note (§11), which is a strictly stronger claim than the account era could make, where a bridged balance was plain visible state.
For anyone holding an integration written against the pre-S1 bridge:
| then | now |
|---|---|
balances: (AssetId, Address) -> u128 |
nothing; a holding is a note with asset = <index> |
| a recipient was a Dilithium2 address | a recipient is blake3 of a shielded address; the action carries the address |
amount: u128 |
amount: u64 (a note's field); anything larger is refused at attestation time |
the registry mapped AssetId -> (chain, token) |
it maps AssetId -> { chain, token, index }, and the note carries the index |
| the relayer fee was paid to the submitter | inbound it is carried and paid to nobody (the deposit is gross); outbound it is still a portion of amount, paid on the destination chain, and a burn destroys exactly amount |
BridgeBurn was one account-debiting transaction |
it is one hidden-asset bundle (two bundles before chain 14) |
rand_getAssetBalance, bridge-status |
gone; rand_getAssets + a wallet-local asset-balance |
| the bridge root committed balance leaves | it commits registry leaves with indices, plus next_index |
The last row is a consensus change: BridgeState::root was re-pinned in S3, so a bridged chain
cannot be carried across that boundary (§4).
| what | who sees it |
|---|---|
| a deposit's amount, asset index, recipient address | everyone, in that one transaction |
| which note a recipient later spends | nobody (the nullifier is a one-way function of nk) |
| a burn's amount, asset, destination, relayer fee | everyone (guardians need it) |
| which notes funded a burn | nobody |
| bridged asset balances | only the holder (or a viewing-key holder) |
| guardian set, emitters, registry, burn log | everyone |
The trust model is unchanged: n·2/3 + 1 colluding guardians can mint arbitrary value. The shielded
design adds one obligation on Rand's side that the account-era bridge did not have: the ledger, not
the submitter, derives the deposit note from the attested amount, so a relayer cannot inflate a
mint.
- Recipients on Rand are identified by a 32-byte hash of a shielded address, not a Dilithium2 address. Wallets print it and source-chain front ends must accept it.
- Every amount on the wire is normalised to
BRIDGE_DECIMALS(8), whatever the source token's own decimal count is (§13): a deposit'sTransferpayload always names an amount in the bridged token's 8-decimal units, and aBridgeBurnreleases in the same units, converted back to the source coin's native units on the destination chain. Only index 0 (RAND) has this chain's own nine decimals. - A token must be listed before it can be deposited or burned — v0.5 removed first-sighting
auto-registration (
docs/tokens.md§5). An attestation naming an unlisted(chain, token)is refusedBridgeError::UnlistedToken, funds safe in the source-chain contract's custody but stuck until a PQ guardian quorum lists it (§18) — list on Rand beforesetTokenon the endpoint, or a deposit can land on the endpoint before Rand will accept its attestation. - The asset index, not the asset id, is what a note and a wallet carry;
rand_getAssetsandrand_getTokensmap between them, andrand_bridgeAssetIdcomputes an id for a(chain, token)pair whether or not it backs anything yet. - A burn costs one RAND fee bundle, one proof (~100 s on a laptop today), and is refused before proving if the amount or the relayer fee is not a whole release unit of the backing's source decimals (§13), or if the backing does not hold enough locked.
Full detail on the RPL token standard is docs/tokens.md; this section is what a bridge integrator
needs of it. The asset registry moved from BridgeState into a ledger-level
crate::ledger::tokens::TokenRegistry (docs/superpowers/specs/2026-09-19-rpl-token-standard-design.md
§12), shared by native and bridged tokens, so that a token is a registry entry either way — not a
contract, and not something the bridge owns alone.
One bridged token, many backings. A Bridge-authority token (MintAuthority::Bridge { backings: Vec<Backing> }) no longer corresponds to a single (chain, token) pair. Its first
bridged registration, zUSD, is backed by USDT and USDC locked on four chains — Ethereum,
BSC and Solana carry both coins, Tron only USDT (Circle discontinued Tron USDC):
chain token (32-byte wire form) coin decimals
2 000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7 USDT 6
2 000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 USDC 6
3 00000000000000000000000055d398326f99059ff775485246999027b3197955 USDT 18
3 0000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d USDC 18
4 000000000000000000000000a614f803b6fd780986a42c78ec9c7f77e6ded13c USDT 6
5 ce010e60afedb22717bd63192f54145a3f965a33bb82d2c7029eb2ce1e208264 USDT 6
5 c6fa7af3bedbad3a3d65f36aabc97431b1bbe4c2d2f6e0e47ca60203452f5d61 USDC 6
Backing { chain: u16, token: [u8; 32], decimals: u8, locked: u64, minted_today: u64, mint_day: u32 } (crates/randprotocol-core/src/ledger/tokens.rs): (chain, token) backs at most one token
in the whole registry (TokenError::BackingTaken), a token has 1..=32 backings
(TokenError::TooManyBackings, NoBackings), and decimals is the source coin's own count,
0..=MAX_BACKING_DECIMALS (18) — never the bridged token's own 8 (TokenError:: BadBackingDecimals). The user accepted the pooled-backing risk (a depeg on any one coin dilutes
every zUSD holder, and the seven coins are fungible into and out of zUSD at par) in exchange for
one short token id instead of seven, with no Rand-side per-backing share cap: the source endpoints'
own pauser and per-token rate caps are the only limit on how much of one coin's risk zUSD carries.
The invariant: total_supply == Σ backings.locked, by construction. lock/release
(TokenRegistry::lock, TokenRegistry::release) are the only two functions that touch a backing's
locked field, and each moves the token's total_supply by the same amount in the same call:
lock (BridgeAttest) does locked += amount; total_supply += amount, release (BridgeBurn)
does locked -= amount; total_supply -= amount. Nothing else writes either field, so the identity
holds after every block — several unit tests carry it through a mixed sequence of locks and
releases, and it is checked, not merely argued, at the points that matter: the token leaf commits
every backing's locked into tokens_root, so two nodes that disagree about one diverge at the
state root, not only in an off-chain audit. On the source-chain side, the deployed endpoints are
fee-free on lock and deduct the whole attested amount on release (the 10 bps and the relayer fee
come out of the release, not the lock), so each backing's locked equals that endpoint's on-chain
custody exactly, modulo messages in flight — the bridge session's rand-bridge-audit tool
reconciles rand_getTokenSupply's per-backing locked against each endpoint's custody.
Before every unbridge, Rand checks amount <= locked. TokenRegistry::check_release
(called from BridgeState::check_burn, in validate_inner's step 7, before either the bundle's
digest or its proof) resolves the named (to_chain, token) to a backing of the burned asset
(TokenError::NotABacking if it backs nothing, or backs a different token) and refuses
TokenError::InsufficientBacking { locked, amount } if the backing does not hold enough — the
token's total supply may well cover the release; this one backing's custody is what has to.
The release-unit rule. The attestation wire always carries an amount at 8 decimals
(BRIDGE_DECIMALS = WIRE_DECIMALS = 8, crates/randprotocol-core/src/ledger/tokens.rs),
whatever the source coin's own decimals is — a deposit of 1 USDT (6 decimals on Ethereum) locks
as 100_000_000 wire units. Releasing an amount that is not a whole multiple of the source coin's
own smallest unit — Backing::release_unit(), 10^(8-d) for d < 8, else 1 — would strand a
fraction in custody forever (below one unit, the endpoint reverts with nothing released at all).
So TokenRegistry::check_release refuses TokenError::NotReleasable { amount, unit } unless both
amount % unit == 0 and relayer_fee % unit == 0 — the relayer fee is carved out of the
amount in native units on the destination chain, so it is exactly as unreleasable — checked before
the locked comparison, byte-cheap. NotReleasable is deliberately not cached as a permanent
admission verdict: a coin refused today may be listed tomorrow. wallet::submit_burn pre-checks
the same rule off one rand_getTokens/rand_getBridgeState read, before any proving:
"{coin} on chain {c} has {d} decimals: the amount and the relayer fee must be multiples of
{unit}".
Per-backing locked and InsufficientBacking guarantee the custody invariant for
redemption (§13). They do nothing to bound minting: a BridgeAttest with a valid guardian
quorum mints whatever it says, and — as of 2026-09-19 — all six mainnet guardian keys live on one
laptop, so a single compromise could mint zUSD with no custody behind it and redeem it against real
custody. docs/superpowers/specs/2026-09-19-bridge-hardening-design.md is the full spec; three
independent layers close the gap, none of which changes the deployed source-chain endpoints, the
attestation wire format or bridge-codec:
- B1 (§15) bounds how much a compromised quorum can mint per day, and gives an operator a fast, unilateral brake.
- B2 (§16) closes a leader's ability to expire a rotated guardian set's grace window, or a mint cap's day, by jumping the block clock.
- B3 (§17) requires a second, post-quantum quorum — Dilithium2 — on every mint, so a classical compromise of the guardian committee alone cannot mint.
- B4 (§18) lets zUSD (and any later bridged token) be listed without a chain cut, under the same PQ quorum.
The cap. Genesis tokens.mint_cap_per_day: u64 (8-decimal units) applies per backing, not
per token: chain 14 sets it to 100_000 * 10^8 (100 000 zUSD per backing per day; seven backings
→ at most 700 000 zUSD/day across all of them). A day is timestamp_ms / 86_400_000 of the
block timestamp, which B2 (§16) makes trustworthy; each backing's own minted_today/mint_day
reset on a newer day (Backing::minted_on, read without a write). TokenRegistry::check_lock
refuses TokenError::MintCapExceeded { cap, minted_today, amount } — checked in validate, cheap,
before the guardian signature work — and it is not cached as a permanent verdict: a deposit
refused today mints fine tomorrow. Two attestations in one block that together exceed the cap: the
second is refused, and applying is otherwise infallible on it.
The pause. Genesis bridge.pause_key: one Dilithium2 public key, distinct from every
pq_guardians entry. Two bundle-less, fee-less actions, routed through ledger/bridge_gov.rs:
PauseMints { nonce, signature }— signed by the pause key alone overM_pause(§19). It can only pause:BridgeError::NoPauseKeyif genesis set none,AlreadyPausedon a repeat,BadPauseNonceon the wrongpause_nonce,BadPauseSignatureon a bad signature — the last is cached as a permanent refusal (it is judged after the nonce, over the transaction's own nonce and chain id under the fixed genesis key, so it depends on the bytes alone — a bundle-less, fee-less pause should not buy a free Dilithium2 verification per replay).UnpauseMints { nonce, pq_signatures }— needs a PQ guardian quorum (§17's rules), never the pause key alone:NotPausedif nothing is paused, then the same PQ structural and signature checks as a mint's co-signatures.
Both bump one ledger counter, pause_nonce, so neither message replays. While paused, every
transfer BridgeAttest is refused BridgeError::MintsPaused (not permanent — admissible the
moment it is lifted). Burns and guardian-set rotations stay open — a pause must never trap
redemption or an in-progress rotation. rand_getBridgeState serves mint_paused, pause_nonce
and pause_key; rand_getAssets rows serve mint_cap_per_day, minted_today and mint_day.
The pause key must be held on a different machine from the one holding the guardian keys — the
whole point is a fast brake that survives a guardian-key compromise.
What the pause does and does not cover. The pause key can only pause; the PQ guardian quorum
alone can UnpauseMints, and that same quorum can ListBacking up to MAX_BACKINGS = 32 coins
onto one bridged token, each with its own fresh mint_cap_per_day — so the effective Rand-side
bound after a pause is lifted is up to 32× one backing's daily cap, not the smaller number a
single-backing token's cap might suggest. The pause is fully effective against the threat it was
built for — guardian daemons tricked or compromised into co-signing a forged mint while the
humans holding the PQ quorum have not — because the pause key needs no guardian cooperation at
all. It is not a defence against theft of the PQ quorum itself: a party that holds the quorum can
unpause in the same block as the next forged attest, or list new backings and mint against their
caps directly, and — in that threat model — could reach the same coins by draining their
source-chain contracts without ever touching Rand. Treat the pause as a brake on a tricked or
compromised bridge operator, not as a lock the PQ quorum cannot open.
A bridged chain's block timestamp decides guardian-set expiry (§3) and, since B1, the mint-cap
day — so a leader who can jump the clock forward can expire a rotated set's 86 400 s grace window
early, or skip past a day's cap. Two rules, both gated on self.bridge.is_some() exactly as the
existing rewind rule is:
- A validity rule, checked on replay too:
block.header.timestamp_ms <= parent.timestamp_ms + MAX_TIMESTAMP_STEP_MS(MAX_TIMESTAMP_STEP_MS = 60_000,crates/randprotocol-core/src/ledger/mod.rs), elseBlockError::TimestampLeap { parent, block, max_step }. Together with the existingTimestampRewindrule (<on the way down, §8), a bridged chain's block time can only move forward, and by at most a minute a block. - A vote rule, never a replay rule: a validator refuses to vote for a block more than
MAX_CLOCK_DRIFT_MS(= 15_000,crates/randprotocol-core/src/consensus/mod.rs) ahead of its own clock (HotStuff::on_proposal). The block stays in the speculative tree — it is valid, and a quorum whose clocks agree with it can still certify it — so replaying committed history never applies this rule; only the step bound above does.
Operational consequence: every validator on a bridged chain needs NTP. A validator whose clock
is more than 15 seconds off real time is a faulty leader (its own proposals may drift past
local_now_ms + MAX_CLOCK_DRIFT_MS and get no votes) and a faulty voter (it may refuse to vote
for an honest, on-time leader's block because its own clock reads far enough behind). Neither
failure halts the chain by itself — the honest majority's clocks still agree — but an
un-synchronised fleet loses liveness margin for nothing. genesis.timestamp_ms must be set close
to the actual launch time: block 1 is measured against it, and a stale genesis time makes block
time visibly lag behind wall clock until B2's step bound lets it catch up, roughly MAX_TIMESTAMP_ STEP_MS per block. A genesis.timestamp_ms set more than 15 s in the future has the opposite
problem: MAX_CLOCK_DRIFT_MS withholds every vote, the leader's own included, until wall time
reaches it, so block 1 simply does not commit until then — set it at or just before launch, never
ahead of it.
Catch-up and the mint-cap day. After any halt long enough for the chain clock to fall behind
wall time, B2's step bound lets block time close the gap at up to MAX_TIMESTAMP_STEP_MS (60 s)
per block — which, at 3 s blocks, can advance the chain clock by a full UTC day in about 76
minutes of real time. B1's cap keys minted_today/mint_day on that same block timestamp, so a
mint-cap day can roll over faster than once per real day while a chain is catching up; bounded to
at most one extra day's cap per full day of accumulated lag, and never during ordinary operation
where block time already tracks wall time.
Full spec: docs/superpowers/specs/2026-09-19-pq-cosignature-bridge.md (a verbatim copy of the
bridge repo's spec/PQ-COSIGNATURE.md). Every BridgeAttest — a deposit and a guardian-set
rotation alike — must carry, beside its ECDSA quorum, a quorum of Dilithium2 signatures by the
same guardians, over the same digest: M = b"rand-bridge-pq-cosign-1" ‖ rand_chain_id (u64 BE) ‖ mu (63 bytes, deterministic signing), where mu is the same keccak256(keccak256(body))
the ECDSA quorum signs. Naming the Rand chain id in M means a testnet co-signature never verifies
on a mainnet Rand chain, even if guardian keys were reused; the guardian-set index is not in M,
so a co-signature survives an ECDSA-set rotation without re-signing.
Action::BridgeAttest gains a last field, pq_signatures: Vec<PqSignature { index: u8, signature: Vec<u8> }> (inside the transaction binding, so it cannot be stripped or swapped on a copy).
crate::bridge::pq::check_pq_structure enforces, in this exact order, cheap before expensive:
quorum(n) <= pq_signatures.len() <= n(n = pq_guardians.len()), elsePqNoQuorum;- indices strictly increasing, else
PqIndexOrder; - every index
< n, elsePqIndexOutOfRange; - every signature exactly 2 420 bytes, else
PqBadSignatureLength; - every signature verifies
Munderpq_guardians[index], elsePqBadSignature— checked last, incheck_pq_quorum_message.
The PQ signers need not be the same indices as the ECDSA signers — each quorum is counted on its
own. Genesis bridge.pq_guardians, index-aligned with guardian_sets[0], is fixed for the
chain's life: a payload-2 rotation moves the ECDSA set only, never the PQ set (a PQ rotation would
be its own governance message, not specified for v0.5). Only PqIndexOrder,
PqIndexOutOfRange and PqBadSignatureLength are cached as permanent admission verdicts — they
are statements about the list's bytes; PqNoQuorum and PqBadSignature are not, since a short or
wrong quorum today says nothing about a resubmission with a different one. rand_getBridgeState
serves pq_guardians; rand bridge-mint @attestation.hex --pq @pq.json --to <rand1…> is the CLI
(docs/cli.md). Conformance: the bridge repo's vectors/pq-cosignatures.json (rand_chain_id 99,
quorum 5 of 6 test guardians).
Chain 14's genesis lists no bridged token: zUSD is registered by transaction, after the cut, by
a faucet-funded deployer wallet that pays the RAND fee, under a PQ guardian quorum's authorisation
— an ordinary address alone can never create a Bridge-authority token. Two new actions
(ledger/bridge_gov.rs), each on an ordinary RAND fee bundle and each requiring the same PQ quorum
rules as B3's mints:
RegisterBridgedToken { name, symbol, salt, chain, token, decimals, nonce, pq_signatures }— registers a newBridge-authority token at the next index, at 8 decimals on Rand, under the genesismint_cap_per_day, with its first backing. Its fee owes the bundle base plus the registry'sregistration_fee.ListBacking { token_index, chain, token, decimals, nonce, pq_signatures }— adds a further backing to an already-registered bridged token. Its fee owes the bundle base alone.
Both share one ledger counter, list_nonce (a message's nonce must equal it; both bump it on
acceptance), and both run the same checks a genesis listing gets: the named chain has a registered
emitter (BridgeError::NoEmitter), (chain, token) backs nothing yet (BackingTaken), the token
has fewer than MAX_BACKINGS (32) backings already, and decimals <= MAX_BACKING_DECIMALS (18). A
wrong list_nonce is BadListNonce { expected, got }. A new backing starts at locked = 0.
The index front-run, and why the operational order matters. ListBacking signs a
token_index, and registering a native token is permissionless — so if a native token happened
to register at the index a pre-signed ListBacking names, the pre-signed message would be
invalidated (a refusal, not a fund-loss risk). The procedure: the PQ guardians sign the
RegisterBridgedToken message first, wait for it to commit, read back the index chain assigned,
and only then sign the six ListBacking messages naming that index — never sign a listing ahead
of the registration it depends on.
Operational order, and why: list on Rand before setToken on the source endpoint. If a coin
were enabled on the endpoint first, a deposit could lock there before Rand has a backing to credit
it against, and the attestation would be refused UnlistedToken — funds safe in the endpoint's
custody, but stuck until Rand is caught up. Listing on Rand first costs nothing (a backing with
locked = 0 accepts no deposits until the endpoint also enables it), so there is no symmetric
failure the other way.
No codec, wire or endpoint change: this is what replaces the RPL spec's originally deferred
guardian governance payload id 3, kept off the wire because bridge-codec is compiled into the
live Solana program and must stay byte-stable through launch and its external audit.
Fixed bytes, never bincode — what an auditor or a hardware signer reproduces by hand, all integers
big-endian, no length prefixes except where a variable-length field needs one
(crates/randprotocol-core/src/bridge/gov.rs):
M_pause = b"rand-bridge-pause-1" ‖ chain_id u64 ‖ nonce u64
M_unpause = b"rand-bridge-pq-unpause-1" ‖ chain_id u64 ‖ nonce u64
M_list = b"rand-bridge-pq-list-1" ‖ chain_id u64 ‖ nonce u64 ‖ token_index u32 ‖ chain u16 ‖ token [32] ‖ decimals u8
M_register = b"rand-bridge-pq-register-1" ‖ chain_id u64 ‖ nonce u64 ‖ u8 len ‖ name ‖ u8 len ‖ symbol ‖ salt [32] ‖ chain u16 ‖ token [32] ‖ decimals u8
M_rotate_pq = b"rand-bridge-pq-rotate-pq-1" ‖ chain_id u64 ‖ nonce u64 ‖ u32 count ‖ key [1312] … (v0.5.4, §21)
M_rotate_pause = b"rand-bridge-pq-rotate-pause-1" ‖ chain_id u64 ‖ nonce u64 ‖ key [1312] (v0.5.4, §21)
M_pause/M_unpause carry pause_nonce; M_list/M_register carry list_nonce;
M_rotate_pq/M_rotate_pause carry rotation_nonce (§21). Each is what bridge/gov.rs's
pause_message/unpause_message/list_message/register_message/rotate_pq_message/
rotate_pause_message builds and what the corresponding validate function re-derives to check a
signature against — never bincode, so the bytes an operator's hardware signer sees are exactly
what gets verified.
Steps 1–5 and round 1 of step 7 are done, on mainnet, 2026-09-20. Round 2 of step 7 is in
progress; step 6 (the guardian-set rotation) is on hold by the user's decision, not merely
pending — the bridge stays on guardian set 0 until the user says otherwise, independent of the
genesis or the cut. Full evidence table: AGENTS.md's v0.5 entry.
- Done. Chain 14 was cut with the fixed
bridgegenesis section (§6 of the handoff,pq_guardiansandpause_keyadded) and atokenssection withregistration_fee(1 RAND) andmint_cap_per_day(100 000 × 10⁸ per backing per day) set, listing no token. Genesis1cff3b7da248d93ab547aef5c05bb7d0d22da510b592dab9cf7374807de7c7ff, buildb3c594c. - Done. The chain-14 faucet is on; the deployer wallet obtained RAND with no prior balance.
- Done. The deployer registered zUSD (
RegisterBridgedToken, first backing Ethereum USDT) under the PQ guardian quorum's signature overM_registeratlist_nonce = 0: transaction7fa28fe6277a82401dcc440ff32da13c5e5763f79826fb17cc508f4c15abdd13, block 256, index 1, idrpl1xtj6k2x8strx8c2d5fjs50ltztck5ykms4ve7nmzmstf6fhn0v0spelqtx. - Done. The guardians read back index 1 and signed the six remaining
ListBackingmessages naming it, submitted one at a time aslist_nonceadvanced 1→7; all seven backings are live. - Done, for all seven backings: listed on Rand (steps 3–4) before
setTokenon each source endpoint (§18); the endpoints were enabled with small caps first (100/transfer, 1000/day). - On hold, by the user's decision (2026-09-20). The guardian-set rotation (set 0 → set 1,
payload 2, signed 5-of-6 by the current set) on the four source endpoints, then the same
attestation submitted to Rand, whose genesis starts at set 0 (§3), is deliberately not run: the
bridge stays on guardian set 0 until the user says otherwise. The set-1 ECDSA and PQ keys are
already generated (
~/.rand-bridge/mainnet-guardian-set1/,~/.rand-bridge/mainnet-pq-set1/) but unused;bridge.pq_guardiansin the genesis is index-aligned with set 1 while every mint so far verifies under set 0's ECDSA signatures (Open Questions §1 of the cut runbook). bridge-06 starts the rotation only on a go the user types in its own session. - Round 1 done (2026-09-20, 1 USDT/coin per source chain): bridge in, mint zUSD, transfer
between two wallets, burn back, release.
rand-bridge-auditon mainnet: custody 0 everywhere, fees accrued in-contract at exactly 10 bps per release, admin-onlywithdrawFees, Rand supply 0 == Σ locked 0. Round 2 confirmed by the user and in progress (2026-09-20): 9 USDT per chain (36 total), mints only, left locked — expected end state 36.00000000 zUSD against 36 USDT in custody, no round-2 burns. Locks (endpoint sequence 1): ETH https://etherscan.io/tx/0xf9bb33bdc89fec2ee82b4dd02226ec9d0b63d27ae50fb341af6e8b95ec937d0a, BSC https://bscscan.com/tx/0xc329ea06440bf4a84383da39b9c67e2484ac96e168558b1a86905242c165c1ff, TRX https://tronscan.org/#/transaction/b0fc155a2264b9dbf5b7cbeac899ae918b3a976aa7af21e003f37435eb9f3269, SOL https://solscan.io/tx/2iAUL44wjhE7pcYeznAwGix5RMb28eTgXVwSRy7qixGASUcwqXx7EhVsrZATyNAjzAKXw2sRNXhQiaLF6ps6w6K3 — all four mints committed (BridgeAttest, 9.00000000 zUSD each; relayer order BSC, SOL, TRX, ETH): BSC block 4686f74d8ba08c1621337e58b57fe94bba93f893fec674e37c199553728cde5e8376, SOL block 47981c7cc5b4dcf50639ad7fd041f6064793287091709f320945d558f4db6c55b6e3, TRX block 4907c2a26eec92756946241b5e6c59c46a3a6645e5c92472f3e221e06d9d9fc74aaa, ETH block 5432329cce2a1818a3cc3f4b60b5e2f13c52bb077fce7ffb34c5a6317b01db095fcd. End state, audited on mainnet byrand-bridge-audit:total_supply3600000000 == Σlocked(900000000 on each of chains 2/3/4/5 USDT), custody − locked = 0.
Everything in this section is switched on by one optional group inside the genesis bridge
section and is absent from chain 14: without it a node behaves byte-for-byte as before — the
genesis hash, the bridge root (rand-bridge-state-4), the token root (rand-token-registry-2),
the storage blobs and every admission verdict are unchanged. Chain 15's genesis carries it:
"bridge": { …, "rules_v2": { "global_mint_cap_per_window": 50000000000000, "cap_window_secs": 86400 } }cap_window_secs must be in 3600..=604800 (an hour to a week); global_mint_cap_per_window is in
eight-decimal token units, a plain number like tokens.mint_cap_per_day, and must be above zero.
The group is committed to the genesis hash as b"bridge_rules_v2" ‖ global_mint_cap_per_window u64 BE ‖ cap_window_secs u32 BE, appended right after the BridgeCommit bytes and only when the
group is present (crates/randprotocol-core/src/genesis.rs). What it turns on:
Action::RotatePqGuardians { new_pq_guardians, nonce, pq_signatures } (wire variant 22) replaces
the whole PQ guardian set; Action::RotatePauseKey { new_pause_key, nonce, pq_signatures }
(variant 23) replaces the pause key. Both are bundle-less and fee-less like PauseMints, both are
governance in the pool (exempt from MempoolFull, ordered first, one pooled per nonce), and both
carry the bridge's new rotation_nonce (rand_getBridgeState.rotation_nonce), which each
accepted rotation bumps — so a quorum signed for one nonce authorises exactly one rotation of
either kind. The quorum is the co-signature's five rules (§17) by the current PQ set over one
of these two messages, all integers big-endian, no terminators, never bincode:
M_rotate_pq = b"rand-bridge-pq-rotate-pq-1" ‖ chain_id u64 ‖ rotation_nonce u64 ‖ count u32 ‖ key₀ [1312] ‖ key₁ [1312] ‖ …
26 bytes 8 8 4 1312 each, in index order
M_rotate_pause = b"rand-bridge-pq-rotate-pause-1" ‖ chain_id u64 ‖ rotation_nonce u64 ‖ key [1312]
29 bytes 8 8 1312
chain_idis this Rand chain's id (14 on chain 14, whatever the next cut names); a rotation signed for another chain never verifies.countis the number of keys, not a byte length; every key is a Dilithium2 public key's raw 1 312 bytes (PublicKey::as_bytes, the same bytesrand_getBridgeState.pq_guardiansshows in hex), concatenated in the order the new set will have — indexiof the new set is the PQ key of the operator of guardianiof the current ECDSA set.M_rotate_pqis46 + 1312 × countbytes long;M_rotate_pauseis45 + 1312 = 1357bytes.rand-bridge-gov pq-rotate-pq/pq-rotate-pause(bridge repo) build exactly these from the chain id, the nonce read offrand_getBridgeStateand the key file(s); the node rebuilds them inbridge/gov.rs'srotate_pq_message/rotate_pause_messageand verifies with the sameverify_pq_messageevery other governance quorum goes through.
Admission order for RotatePqGuardians, cheapest first: the bridge gate (Disabled), the
rules_v2 gate (RulesV2Disabled — a genesis constant, cached as permanent), the quorum's
structure (rules 1–3), the nonce (BadRotationNonce { expected, got }), then the new set's shape
— exactly the genesis rules: its length equals the current ECDSA set's (PqSetLengthMismatch),
every key is exactly 1 312 bytes (BadPqGuardianKey { index, len }, permanent), no key repeats
(DuplicatePqGuardian, permanent), and none is the pause key (GuardianIsPauseKey) — and the
Dilithium2 verifications last. Apply replaces pq_guardians whole and bumps rotation_nonce;
the superseded set signs nothing further (its quorum over the next nonce fails at the signature).
RotatePauseKey: the same gates, structure and nonce, then the key's length
(BadPauseKeyLength { len }, permanent) and that it is no PQ guardian's (PauseKeyIsGuardian),
then the quorum. Apply replaces pause_key and bumps the nonce; the old pause key's M_pause
signature no longer pauses. There is no grace window on either: the rotation is what a compromise
response needs to be immediate.
tx_json renders them as rotate_pq_guardians (new_pq_guardians hex, nonce, pq_signers)
and rotate_pause_key (new_pause_key hex, nonce, pq_signers) — docs/rpc.md.
B1's per-backing cap (tokens.mint_cap_per_day) is measured over a rolling window of
cap_window_secs of the block timestamp instead of the UTC calendar day, and a second,
registry-wide cap — global_mint_cap_per_window over every backing of every token together — is
judged after it (TokenError::GlobalMintCapExceeded { cap, minted, amount }). Under the day
rule, the cap at 23:59:59 and the cap again at 00:00:01 was 2× the cap in two seconds; under the
window the second is refused and admitted again exactly cap_window_secs after the first.
The accounting (ledger/tokens.rs's MintWindow): a window is a ring of slots of
⌈cap_window_secs / 24⌉ seconds (an hour for a day's window, seven hours for a week's), keyed by
block_secs / slot_secs; a deposit adds to its slot; a slot counts while its last second is
less than cap_window_secs before the block time (strictly), so the count is never below the
exact rolling sum — it is counted too strictly by up to one slot, never twice — and a deposit
made in the last second of its slot leaves the window exactly cap_window_secs later. Slots that
have left the window are dropped on the next deposit, so a window holds at most 26 slots. There
is one window per backing (keyed by token index, chain and token address) and one global window;
all of them are consensus state, folded into the token root, which becomes
rand-token-registry-3 ‖ … ‖ bincode(RegistryExt). The day counters (minted_today,
mint_day) keep moving under rules v2 and rand_getAssets keeps showing them, but nothing reads
them for admission any more. The refusal shape is unchanged — MintCapExceeded { cap, minted_today, amount }, where minted_today now reads the window's count.
RegisterBridgedToken and ListBacking are refused MintsPaused while mint_paused — judged
after the list_nonce, before the quorum — under rules v2 only: chain 14's ledger keeps accepting
what it accepts today. Burns and both rotations stay open while paused, as before.
BridgeState gains rotation_nonce and rules_v2; with the group present the bridge root is
rand-bridge-state-5 over the v4 bytes followed by bincode(rotation_nonce, rules_v2). Storage
keeps the v1 BridgeMeta blob exactly as it was under bridge_state and writes the v2 half
(BridgeMetaV2 { rotation_nonce, rules }) under a second key, bridge_state_v2, only on a chain
that has the group; the registry's v2 half (RegistryExt: TOK-1's max_tokens and the windows)
likewise rides tokens_v2 beside the unchanged tokens blob. A chain-14 database therefore never
gains a key and keeps its strict v1 decode; a v0.5.4 node rolls onto chain 14 like any node-only
build. rand_getBridgeState gains rotation_nonce (0 on chain 14) and rules_v2
({ "global_mint_cap_per_window": "<decimal string>", "cap_window_secs": 86400 }, or null).
A chain cut from a running bridged chain inherits custody the source contracts still hold: coins
locked on the far side against notes somebody on the old chain still owns. Chain 15 is cut from
chain 14 with ten zUSD in a third party's wallet, backed by 9 USDT on Tron and 1 USDT on Solana.
Genesis can now start a listed token holding exactly that, so custody − locked = 0 and
total_supply == Σ backings.locked from block 0:
tokens.tokens[].backings[].locked(optional, eight-decimal token units, a plain number likemint_cap_per_day): what the backing starts with.Genesis::buildapplies it throughTokenRegistry::lockitself at the genesis block's time — the one writer that moveslockedandtotal_supplytogether — so a genesis lock is judged like a deposit in the chain's first second (the note bound, the per-backing daily cap, the rules-v2 windows) and counts toward that first day's cap. Absent, nothing changes; it is bound to the genesis hash through the state root (a backing'slockedis in its token's leaf).alloc[].opening.asset(optional, default 0 = RAND and then absent from the file): the registry index of a token the same genesis lists (the first listed token is 1). The commitment is recomputed at that index withfromthe zero word — the deposit commitment aBridgeAttestwould append — and the note never counts toward the RAND supply (genesis_deposited).- The rule (
Genesis::validate): per listed token, Σ its notes == Σ its backings'locked(TokenSupplyMismatch); a note naming an index no listed token holds isBadNoteAsset.
rand-node alloc-note --to <rand1…> --amount <whole units, e.g. 10> --asset <index> prints one such alloc
entry — --amount scaled by the asset's decimals (nine for RAND, eight for a bridged token: 10
at asset 1 is 10 zUSD, 10^9 units) — sealed to the owner's KEM key like a genesis --alloc note, so the owner's wallet finds it
on its first scan. The token keeps its id across the cut when it is listed with the old chain's
name, symbol and salt (zUSD: "Shielded USD", "zUSD", salt 27e77272…1d60 → 32e5ab28…7b1f).
The bridge section carries the rest of the old chain's bridge position, each field optional and
committed to the genesis hash under its own tag only when present (b"bridge_guardian_set_index"
‖ u32 BE, then b"bridge_burn_sequence" ‖ u64 BE, after the rules-v2 bytes), and in the bridge
root either way:
guardian_set_index: the index the listedguardiansstart as. The genesis bridge holds that one set ascurrent_set— an attestation under any other index isUnknownGuardianSet, and the next rotation must carryindex + 1(BadUpgradeIndexotherwise).u32::MAXis refused. Chain 15 starts at 1, where the source endpoints already are.burn_sequence: the sequence the first outbound burn carries. Chain 14's burns ended at 6, so chain 15 starts at 7 and no endpoint or daemon keyed by sequence sees one twice.
rand_getBridgeState serves both as it always has (guardian_set_index, burn_sequence);
rand_getTokens/rand_getAssets serve the genesis locked and total_supply, and
rand_getSupply's genesis_deposited stays RAND-only.
A chain cut from a running bridged chain keeps the source endpoints — their emitter addresses,
their per-endpoint sequence counters and, when the genesis starts at the guardian set the
endpoints already hold (chain 15 at set 1, §22), the ECDSA signers too. And mu does not name the
Rand chain. So every lock the old chain already minted is, on its successor, an attestation whose
ECDSA quorum verifies; spent starts empty, and the one check left between it and a second mint
is the PQ co-signature, which binds the chain id — and which a guardian only withholds while its
local store remembers having signed that (chain, sequence). A quorum of guardians brought up
with fresh stores (a rotation, a rebuilt droplet) rescans each source from its configured start
and co-signs every historical lock for the new chain id: zUSD minted with no custody behind it,
bounded only by the rules-v2 global cap (4 000 zUSD a day on chain 15).
The floor is an optional, genesis-fixed field of the bridge section: per source chain, the
lowest sequence a transfer attestation from that chain's emitter may carry.
"bridge": { …, "guardian_set_index": 1, "burn_sequence": 7,
"min_inbound_sequence": { "2": 2, "3": 2, "4": 2, "5": 2 } }- The rule (
BridgeState::check_attest): a transfer whose(emitter_chain, sequence)is below the chain's floor is refusedBelowReplayFloor { chain, sequence, floor }, whatever quorum and co-signature it carries — decided after the emitter check and before any signature. A chain the floor does not name has no floor; guardian-set rotations (Rand's own governance messages) are not judged by it. The verdict is the body's own bytes against a genesis constant, so admission caches it as permanent (admission::is_permanent). - Validation (
Genesis::validate): present means non-empty; every key a chain with a registeredemittersentry (a floor on any other chain is dead —WrongEmittercomes first — and almost certainly a mistyped id that leaves the chain meant unguarded); every floor above 0 (0 refuses nothing, and is what an unset cut variable writes). A chain written twice in the file, under any spelling ("2"and"02"), is a parse error rather than a silently kept last value. - Commitments. Absent — chain 15 and every earlier chain — it is committed nowhere: the file,
the genesis hash, the bridge root and the database are byte-for-byte what they were
(
chain_15s_genesis_file_still_builds_chain_15pinscc30e085…). Present, it is committed to the genesis hash under its own tag afterbridge_burn_sequence(b"bridge_min_inbound_sequence"‖ u32 BE count ‖ (u16 BE chain ‖ u64 BE floor)… in ascending chain order), and it wraps the bridge root:blake3("rand-bridge-replay-floor-1" ‖ root ‖ bincode(floor)). The floor never moves, so the genesis hash alone would bind it; the root carries it too, like the emitter table, so a node whose store lost it disagrees at its next block rather than at the first old lock it admits. - Storage and RPC. Stored under its own
metakey (bridge_replay_floor), written only when present, and restored byload_bridge;rand_getBridgeStateserves it asmin_inbound_sequence(nullwithout one).
Setting it at a cut. Each source chain's floor is one past the last lock the old chain
observed from that source — the highest sequence any BridgeAttest on the old chain carried
for that emitter chain, plus one (read off the old chain's committed attestations, or the
guardians' signed-message stores, before the old chain stops). Every lock at or above it is one the
new chain must mint; every lock below it the old chain already minted. Two consequences for the
cut: a lock made on a source after the old chain's last attest but before the cut is above the
floor and mints on the new chain (as it should — it was never minted); a lock the old chain never
minted but that sits below a later minted one (an out-of-order relay) would be stranded under
the floor, so drain every in-flight lock on the old chain before reading the floors, or carry its
custody at genesis (§22) the way chain 15 carried the residue. The cut script sets the field with
the rest of the bridge section it splices in (docs/deploy.md "The next cut", and
deploy/cut-chain15-genesis.sh's bridge block is where the next script's copy takes it).
Until a chain carries the floor — chain 15 today — the rule is operational. A guardian brought
up with an empty store (a new operator, a rotation, a rebuilt droplet, a store restored from an
old backup) must have its source cursors set past the previous chain's last observed lock before
it starts, never left to the configuration's start: start_block for an EVM/Tron source (the
first block after the last lock chain 14 minted), start_sequence for Solana (that lock's sequence
plus one). A guardian left to rescan from the deployment block re-observes every chain-14 lock as
new and, with an empty store, co-signs it for chain 15; a quorum of such guardians re-mints them.
Bringing more than one guardian up fresh at once, or any fresh guardian before its cursors are
set, is the failure this section exists for.