Summary
Add root finalization delay or challenge period before minting from new epoch — merkle_bridge's update_root makes a newly-published Merkle root immediately usable by mint_wrapped in the same transaction block, giving no window to detect and freeze a bad root before wrapped tokens are minted against it.
Social Media Link
Let's collaborate on Discord. And ensure to star our repo.
Problem Statement
Confirmed in stellar-core/verifiable-registry/contracts/merkle_bridge/src/lib.rs:
-
update_root writes and finalizes a root in one step: lib.rs:251-293 stores DataKey::MerkleRoot(epoch_id), updates DataKey::CurrentEpoch, and emits RootUpdatedEvent — there is no intermediate "proposed" state and no delay before the root becomes mintable.
-
mint_wrapped accepts proofs against any root the instant it's stored: lib.rs:307-395 reads DataKey::MerkleRoot(epoch_id) directly (line 330-335) with no check on how long ago the root was published — a malicious or compromised Updater key (the sole require_updater-gated caller of update_root, line 258) could publish a fraudulent root and have credits minted against it within the same ledger close.
-
require_updater is the only defense on root publication: lib.rs:503-514 checks caller == updater, a single address with no multisig, timelock, or secondary review — this contract has exactly one trusted key gating the entire bridge's integrity, and set_updater (lib.rs:224-239) lets the admin rotate it just as instantly.
-
No DataKey exists for a pending/unconfirmed root: the enum (lib.rs:9-28) has Admin, Updater, CarbonAssetContract, CurrentEpoch, MerkleRoot(u64), MintedCredit(String), RetiredCredit(String), NextTokenId — no PendingRoot(u64) or RootPublishedAt(u64) to gate a delay window against.
-
No mechanism exists to freeze or reject a root before it's used: unlike mark_retired, which lets the updater invalidate a credit after the fact (lib.rs:406-432), there is no way to invalidate a root before any mint_wrapped calls have consumed it — once update_root succeeds, the root is immediately live.
-
NonSequentialEpoch enforcement makes roots strictly ordered but not delayed: lib.rs:267-270 only checks epoch_id != current_epoch + 1 — sequential ordering is unrelated to timing; a bad actor publishing epoch N+1 correctly in sequence is not slowed down at all.
-
No event exists for "root published, pending finalization" vs. "root finalized": events.rs-equivalent event definitions in lib.rs:39-63 (CreditBridgedEvent, RootUpdatedEvent, CreditRetiredEvent) have no distinct pending/finalized pair that off-chain monitors could use to raise an alert during the window before finalization.
-
Tests only cover immediate-availability behavior: test_update_root, test_multiple_epochs, and the various mint_wrapped tests in the #[cfg(test)] mod tests block all call update_root immediately followed by mint_wrapped in the same test, with no delay simulated — there is no test asserting a root is unusable until some challenge period elapses.
Required Changes
-
Add DataKey::PendingRoot(u64) (mirroring MerkleRoot(u64)) and DataKey::RootPublishedAt(u64) to store a newly-submitted root separately from the finalized/mintable one, along with the ledger timestamp it was published.
-
Add a configurable DataKey::ChallengePeriod: u64 (seconds), settable only by admin, defaulting to a conservative value (e.g. 3600 seconds).
-
Change update_root to write into PendingRoot(epoch_id) / RootPublishedAt(epoch_id) and emit RootProposedEvent { epoch_id, root_hash, proposed_by } instead of immediately populating MerkleRoot(epoch_id).
-
Add finalize_root(env, caller: Address, epoch_id: u64) -> Result<(), MerkleBridgeError> that requires env.ledger().timestamp() >= RootPublishedAt(epoch_id) + ChallengePeriod, then copies PendingRoot(epoch_id) into MerkleRoot(epoch_id), updates CurrentEpoch, and emits RootUpdatedEvent — callable by anyone (permissionless finalization) once the delay has elapsed, or restricted to updater/admin if a stricter model is preferred.
-
Add freeze_pending_root(env, caller: Address, epoch_id: u64) -> Result<(), MerkleBridgeError> restricted to admin, allowing a suspicious pending root to be discarded before finalization — emit RootFrozenEvent { epoch_id, frozen_by }.
-
Add MerkleBridgeError::RootNotFinalized, MerkleBridgeError::ChallengePeriodNotElapsed, and MerkleBridgeError::RootAlreadyFrozen variants.
-
Update mint_wrapped to continue reading only from finalized MerkleRoot(epoch_id) (unchanged), so it naturally rejects proofs against a still-pending root with the existing RootNotFound error, or a more specific RootNotFinalized if distinguishing the two states is preferred.
-
Add get_pending_root(env, epoch_id: u64) -> Option<(BytesN<32>, u64)> (root hash + published-at timestamp) as a view function.
-
Update existing tests to insert a ledger-time advance (via env.ledger().set_timestamp(...)) between update_root/propose and any mint_wrapped call, and add new tests: minting against a root before the challenge period elapses fails; finalize_root before the delay elapses fails with ChallengePeriodNotElapsed; finalize_root after the delay succeeds and subsequent mint_wrapped calls work; freeze_pending_root prevents finalization of a discarded root.
Acceptance Criteria
- A newly-submitted root is not usable by
mint_wrapped until finalize_root succeeds.
finalize_root enforces the configured ChallengePeriod has elapsed since the root was proposed.
freeze_pending_root lets admin discard a pending root before it is finalized.
RootProposedEvent, RootUpdatedEvent (on finalize), and RootFrozenEvent are each emitted at the correct stage.
CurrentEpoch only advances on finalization, not on proposal.
- Existing sequential-epoch enforcement (
NonSequentialEpoch) continues to apply to proposed epochs.
get_pending_root correctly exposes an outstanding proposal's root hash and publish timestamp.
- Test coverage includes: premature mint rejection, premature finalize rejection, successful delayed finalize-then-mint flow, and admin freeze of a pending root.
Directory to Work on:
stellar-core/verifiable-registry/
Summary
Add root finalization delay or challenge period before minting from new epoch —
merkle_bridge'supdate_rootmakes a newly-published Merkle root immediately usable bymint_wrappedin the same transaction block, giving no window to detect and freeze a bad root before wrapped tokens are minted against it.Social Media Link
Let's collaborate on Discord. And ensure to star our repo.
Problem Statement
Confirmed in
stellar-core/verifiable-registry/contracts/merkle_bridge/src/lib.rs:update_rootwrites and finalizes a root in one step:lib.rs:251-293storesDataKey::MerkleRoot(epoch_id), updatesDataKey::CurrentEpoch, and emitsRootUpdatedEvent— there is no intermediate "proposed" state and no delay before the root becomes mintable.mint_wrappedaccepts proofs against any root the instant it's stored:lib.rs:307-395readsDataKey::MerkleRoot(epoch_id)directly (line 330-335) with no check on how long ago the root was published — a malicious or compromisedUpdaterkey (the solerequire_updater-gated caller ofupdate_root, line 258) could publish a fraudulent root and have credits minted against it within the same ledger close.require_updateris the only defense on root publication:lib.rs:503-514checkscaller == updater, a single address with no multisig, timelock, or secondary review — this contract has exactly one trusted key gating the entire bridge's integrity, andset_updater(lib.rs:224-239) lets the admin rotate it just as instantly.No
DataKeyexists for a pending/unconfirmed root: the enum (lib.rs:9-28) hasAdmin,Updater,CarbonAssetContract,CurrentEpoch,MerkleRoot(u64),MintedCredit(String),RetiredCredit(String),NextTokenId— noPendingRoot(u64)orRootPublishedAt(u64)to gate a delay window against.No mechanism exists to freeze or reject a root before it's used: unlike
mark_retired, which lets the updater invalidate a credit after the fact (lib.rs:406-432), there is no way to invalidate a root before anymint_wrappedcalls have consumed it — onceupdate_rootsucceeds, the root is immediately live.NonSequentialEpochenforcement makes roots strictly ordered but not delayed:lib.rs:267-270only checksepoch_id != current_epoch + 1— sequential ordering is unrelated to timing; a bad actor publishing epoch N+1 correctly in sequence is not slowed down at all.No event exists for "root published, pending finalization" vs. "root finalized":
events.rs-equivalent event definitions inlib.rs:39-63(CreditBridgedEvent,RootUpdatedEvent,CreditRetiredEvent) have no distinct pending/finalized pair that off-chain monitors could use to raise an alert during the window before finalization.Tests only cover immediate-availability behavior:
test_update_root,test_multiple_epochs, and the variousmint_wrappedtests in the#[cfg(test)] mod testsblock all callupdate_rootimmediately followed bymint_wrappedin the same test, with no delay simulated — there is no test asserting a root is unusable until some challenge period elapses.Required Changes
Add
DataKey::PendingRoot(u64)(mirroringMerkleRoot(u64)) andDataKey::RootPublishedAt(u64)to store a newly-submitted root separately from the finalized/mintable one, along with the ledger timestamp it was published.Add a configurable
DataKey::ChallengePeriod: u64(seconds), settable only by admin, defaulting to a conservative value (e.g. 3600 seconds).Change
update_rootto write intoPendingRoot(epoch_id)/RootPublishedAt(epoch_id)and emitRootProposedEvent { epoch_id, root_hash, proposed_by }instead of immediately populatingMerkleRoot(epoch_id).Add
finalize_root(env, caller: Address, epoch_id: u64) -> Result<(), MerkleBridgeError>that requiresenv.ledger().timestamp() >= RootPublishedAt(epoch_id) + ChallengePeriod, then copiesPendingRoot(epoch_id)intoMerkleRoot(epoch_id), updatesCurrentEpoch, and emitsRootUpdatedEvent— callable by anyone (permissionless finalization) once the delay has elapsed, or restricted to updater/admin if a stricter model is preferred.Add
freeze_pending_root(env, caller: Address, epoch_id: u64) -> Result<(), MerkleBridgeError>restricted to admin, allowing a suspicious pending root to be discarded before finalization — emitRootFrozenEvent { epoch_id, frozen_by }.Add
MerkleBridgeError::RootNotFinalized,MerkleBridgeError::ChallengePeriodNotElapsed, andMerkleBridgeError::RootAlreadyFrozenvariants.Update
mint_wrappedto continue reading only from finalizedMerkleRoot(epoch_id)(unchanged), so it naturally rejects proofs against a still-pending root with the existingRootNotFounderror, or a more specificRootNotFinalizedif distinguishing the two states is preferred.Add
get_pending_root(env, epoch_id: u64) -> Option<(BytesN<32>, u64)>(root hash + published-at timestamp) as a view function.Update existing tests to insert a ledger-time advance (via
env.ledger().set_timestamp(...)) betweenupdate_root/propose and anymint_wrappedcall, and add new tests: minting against a root before the challenge period elapses fails;finalize_rootbefore the delay elapses fails withChallengePeriodNotElapsed;finalize_rootafter the delay succeeds and subsequentmint_wrappedcalls work;freeze_pending_rootprevents finalization of a discarded root.Acceptance Criteria
mint_wrappeduntilfinalize_rootsucceeds.finalize_rootenforces the configuredChallengePeriodhas elapsed since the root was proposed.freeze_pending_rootlets admin discard a pending root before it is finalized.RootProposedEvent,RootUpdatedEvent(on finalize), andRootFrozenEventare each emitted at the correct stage.CurrentEpochonly advances on finalization, not on proposal.NonSequentialEpoch) continues to apply to proposed epochs.get_pending_rootcorrectly exposes an outstanding proposal's root hash and publish timestamp.Directory to Work on:
stellar-core/verifiable-registry/