From ee5c747f508b4ad6cdea843361acc0c837193e32 Mon Sep 17 00:00:00 2001 From: Eren Yegit <115787683+erenyegit@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:33:46 +0300 Subject: [PATCH 1/2] feat(bridge): add destination claim flow Add the destination side of the one-way bridge: a chain anchors an attested foreign state root, then claims a transfer by proving its record against that root and minting the mapped asset to the recipient. - AnchorForeignRoot: the genesis-configured attestor pins a foreign state root, keyed by (source_chain_id, view) with a strictly monotonic view per source chain. This is a trusted/authority-attested anchor for the MVP, not trustless finalization verification. - Claim { source_chain_id, source_view, record, proof }: verifies the record targets this chain, looks up the anchored root for the view, checks the embedded StateProof with verify_state_update, and marks the record consumed exactly once. The proof decodes with fixed bridge-internal bounds so the operation stays Read. - Settlement is lock-and-mint, wired in the coins-chain integration layer: a genesis-configured AssetId -> CoinId mapping selects the local wrapped coin, and a new coins Ledger::bridge_mint credits the recipient by increasing that coin's supply. The claim's consume marker and the mint share one inner overlay, and the claim's events are buffered and emitted only after settlement commits, so a missing mapping or a mint failure reverts both state and events. Source and destination are separate states; the claim never touches the source escrow. A dedicated test proves a real BridgeOperation::Lock writes a record that verify_state_update accepts, so the lock -> proof -> claim chain holds. Adds Clone/Debug/PartialEq + manual Eq to common's StateProof so it can be embedded in the claim operation. Out of scope (follow-ups): cryptographic finalization verification, relayer proof-generation from full production blocks, multisig claims, reverse redemption, relayer/RPC wiring, and genesis JSON configuration. --- bridge/src/events.rs | 103 ++++ bridge/src/genesis.rs | 32 +- bridge/src/ledger.rs | 159 +++++- bridge/src/lib.rs | 13 +- bridge/src/record.rs | 131 ++++- bridge/src/tests/claim.rs | 466 ++++++++++++++++++ bridge/src/tests/ledger.rs | 28 +- bridge/src/tests/mod.rs | 1 + bridge/src/transaction.rs | 103 +++- coins/src/ledger.rs | 19 + common/src/state_db.rs | 6 + examples/coins/chain/src/bridge_assets.rs | 50 ++ examples/coins/chain/src/lib.rs | 1 + examples/coins/chain/src/runtime.rs | 68 ++- .../coins/chain/src/tests/bridge_claim.rs | 356 +++++++++++++ examples/coins/chain/src/tests/mod.rs | 1 + 16 files changed, 1483 insertions(+), 54 deletions(-) create mode 100644 bridge/src/tests/claim.rs create mode 100644 examples/coins/chain/src/bridge_assets.rs create mode 100644 examples/coins/chain/src/tests/bridge_claim.rs diff --git a/bridge/src/events.rs b/bridge/src/events.rs index a7a4a48..f50846e 100644 --- a/bridge/src/events.rs +++ b/bridge/src/events.rs @@ -2,10 +2,15 @@ use crate::record::{AssetId, ChainId, TransferRecordId}; use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::sha256::Digest; use nunchi_common::{Address, Event}; /// Topic for the event emitted when a source-chain lock records a transfer. pub const TRANSFER_LOCKED_EVENT: &[u8] = b"bridge.transfer_locked.v1"; +/// Topic for the event emitted when an attested foreign root is anchored. +pub const FOREIGN_ROOT_ANCHORED_EVENT: &[u8] = b"bridge.foreign_root_anchored.v1"; +/// Topic for the event emitted when a transfer is claimed on the destination chain. +pub const TRANSFER_CLAIMED_EVENT: &[u8] = b"bridge.transfer_claimed.v1"; /// Emitted when a source-chain lock writes a [`crate::record::BridgeTransferRecord`]. #[derive(Clone, Debug, Eq, PartialEq)] @@ -70,3 +75,101 @@ pub fn transfer_locked_event(value: TransferLocked) -> Event { value.encode(), ) } + +/// Emitted when the attestor anchors a foreign state root. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ForeignRootAnchored { + pub source_chain_id: ChainId, + pub view: u64, + pub state_root: Digest, +} + +impl Write for ForeignRootAnchored { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.source_chain_id.write(buf); + self.view.write(buf); + self.state_root.write(buf); + } +} + +impl Read for ForeignRootAnchored { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + source_chain_id: ChainId::read(buf)?, + view: u64::read(buf)?, + state_root: Digest::read(buf)?, + }) + } +} + +impl EncodeSize for ForeignRootAnchored { + fn encode_size(&self) -> usize { + self.source_chain_id.encode_size() + self.view.encode_size() + self.state_root.encode_size() + } +} + +/// Build the [`Event`] for an anchored foreign root. +pub fn foreign_root_anchored_event(value: ForeignRootAnchored) -> Event { + Event::new( + bytes::Bytes::from_static(FOREIGN_ROOT_ANCHORED_EVENT), + value.encode(), + ) +} + +/// Emitted when a transfer is claimed (proven and marked consumed) on the destination chain. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferClaimed { + pub record_id: TransferRecordId, + pub source_chain_id: ChainId, + pub source_view: u64, + pub source_asset: AssetId, + pub recipient: Address, + pub amount: u128, +} + +impl Write for TransferClaimed { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.record_id.write(buf); + self.source_chain_id.write(buf); + self.source_view.write(buf); + self.source_asset.write(buf); + self.recipient.write(buf); + self.amount.write(buf); + } +} + +impl Read for TransferClaimed { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + record_id: TransferRecordId::read(buf)?, + source_chain_id: ChainId::read(buf)?, + source_view: u64::read(buf)?, + source_asset: AssetId::read(buf)?, + recipient: Address::read(buf)?, + amount: u128::read(buf)?, + }) + } +} + +impl EncodeSize for TransferClaimed { + fn encode_size(&self) -> usize { + self.record_id.encode_size() + + self.source_chain_id.encode_size() + + self.source_view.encode_size() + + self.source_asset.encode_size() + + self.recipient.encode_size() + + self.amount.encode_size() + } +} + +/// Build the [`Event`] for a claimed transfer. +pub fn transfer_claimed_event(value: TransferClaimed) -> Event { + Event::new( + bytes::Bytes::from_static(TRANSFER_CLAIMED_EVENT), + value.encode(), + ) +} diff --git a/bridge/src/genesis.rs b/bridge/src/genesis.rs index a09b355..14f831c 100644 --- a/bridge/src/genesis.rs +++ b/bridge/src/genesis.rs @@ -1,27 +1,43 @@ //! Bridge module genesis. //! //! The bridge needs to know its own chain identity so every lock can stamp a record's -//! `source_chain_id`. That identity is pinned once here; the derivation policy for a chain's id -//! (for example a genesis/config hash) lives with the chain, not this module. +//! `source_chain_id`. A chain that receives bridged transfers also pins the account allowed to +//! anchor foreign roots. Both are set once here; the derivation of a chain's id and the choice of +//! attestor live with the chain, not this module. -use crate::record::{set_local_chain_id, ChainId}; -use nunchi_common::StateStore; +use crate::record::{set_attestor, set_local_chain_id, ChainId}; +use nunchi_common::{Address, StateStore}; /// Genesis configuration for the bridge module. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct BridgeGenesis { /// This chain's bridge id, stamped as `source_chain_id` on every lock. pub local_chain_id: ChainId, + /// Account allowed to anchor foreign roots on this chain. A chain that only originates + /// transfers (never receives claims) can leave this `None`. + pub attestor: Option
, } impl BridgeGenesis { - /// Create a genesis config pinning `local_chain_id`. + /// Create a genesis config pinning `local_chain_id`, with no anchor attestor. pub fn new(local_chain_id: ChainId) -> Self { - Self { local_chain_id } + Self { + local_chain_id, + attestor: None, + } } - /// Pin this chain's [`ChainId`] into bridge state. + /// Set the account allowed to anchor foreign roots on this chain. + pub fn with_attestor(mut self, attestor: Address) -> Self { + self.attestor = Some(attestor); + self + } + + /// Pin this chain's [`ChainId`] (and attestor, if any) into bridge state. pub fn apply(&self, store: &mut S) { set_local_chain_id(store, &self.local_chain_id); + if let Some(attestor) = &self.attestor { + set_attestor(store, attestor); + } } } diff --git a/bridge/src/ledger.rs b/bridge/src/ledger.rs index 06584e9..3921325 100644 --- a/bridge/src/ledger.rs +++ b/bridge/src/ledger.rs @@ -1,17 +1,26 @@ -//! Deterministic bridge state machine for source-chain lock operations. +//! Deterministic bridge state machine for lock, anchor, and claim operations. //! -//! The ledger stays decoupled from any asset module: a lock records an authenticated -//! [`BridgeTransferRecord`] and advances the sender's bridge nonce, but the actual movement of the -//! locked asset into escrow is performed by the chain's integration layer alongside this call, in -//! the same overlay. +//! The ledger stays decoupled from any asset module. A **lock** records an authenticated +//! [`BridgeTransferRecord`]; an **anchor** pins an attested foreign state root; a **claim** proves a +//! record against an anchored root and marks it consumed exactly once. The actual asset movement +//! (escrow on lock, mint on claim) is performed by the chain's integration layer alongside these +//! calls, in the same overlay, keyed off the returned [`BridgeReceipt`]. -use crate::events::{transfer_locked_event, TransferLocked}; +use crate::events::{ + foreign_root_anchored_event, transfer_claimed_event, transfer_locked_event, ForeignRootAnchored, + TransferClaimed, TransferLocked, +}; use crate::record::{ - bridge_nonce, local_chain_id, put_transfer_record, set_bridge_nonce, AssetId, - BridgeTransferRecord, TransferRecordId, + attestor, bridge_nonce, foreign_root, is_consumed, latest_foreign_view, local_chain_id, + mark_consumed, put_foreign_root, put_transfer_record, set_bridge_nonce, set_latest_foreign_view, + transfer_record_key, AssetId, BridgeTransferRecord, ChainId, ForeignRoot, TransferRecordId, }; use crate::transaction::{BridgeOperation, Transaction}; -use nunchi_common::{state_db::StateError, Authorization, EventSink, StateStore}; +use commonware_codec::Encode; +use nunchi_common::{ + state_db::{verify_state_update, StateError}, + Address, Authorization, EventSink, StateStore, +}; use nunchi_crypto::SignatureError; use thiserror::Error; @@ -32,6 +41,22 @@ pub enum BridgeError { SelfBridge, #[error("bridge chain id is not configured")] ChainNotConfigured, + #[error("anchor attestor is not configured")] + AttestorNotConfigured, + #[error("only the configured attestor may anchor foreign roots")] + NotAttestor, + #[error("stale anchor: latest view is {latest}, got {submitted}")] + StaleAnchor { latest: u64, submitted: u64 }, + #[error("claim record does not belong to the claimed source chain")] + ClaimSourceMismatch, + #[error("claim record is not destined for this chain")] + WrongDestination, + #[error("no anchored foreign root for the claimed (source chain, view)")] + MissingAnchor, + #[error("claim proof does not authenticate the record under the anchored root")] + InvalidProof, + #[error("transfer record has already been claimed")] + AlreadyClaimed, #[error("state storage error: {0}")] Storage(String), } @@ -42,6 +67,24 @@ impl From for BridgeError { } } +/// The validated outcome of a bridge operation. The integration layer performs the matching asset +/// movement in the same overlay: escrow the locked coins on [`BridgeReceipt::Locked`], mint the +/// mapped asset to the recipient on [`BridgeReceipt::Claimed`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BridgeReceipt { + /// A source-chain lock recorded `record_id`. + Locked(TransferRecordId), + /// An attested foreign root was anchored for `source_chain_id` at `view`. + Anchored { source_chain_id: ChainId, view: u64 }, + /// A transfer was claimed and marked consumed; the recipient must be credited `amount` of the + /// coin mapped from `source_asset`. + Claimed { + source_asset: AssetId, + recipient: Address, + amount: u128, + }, +} + /// Deterministic state machine for bridge operations over a [`StateStore`] backend. pub struct BridgeLedger { store: S, @@ -63,16 +106,15 @@ impl BridgeLedger { self.store } - /// Verify, authorize, and apply a signed bridge transaction, returning the id of the recorded - /// transfer. + /// Verify, authorize, and apply a signed bridge transaction, returning a [`BridgeReceipt`] the + /// integration layer uses to perform the matching asset movement in the same overlay. /// - /// Records are content-addressed and append-only; the sender's monotonic bridge nonce gives - /// each of their transfers a distinct id even when the other fields are identical. + /// All operations share single-key authorization and a per-account monotonic bridge nonce. pub async fn apply_transaction( &mut self, tx: &Transaction, mut events: Events, - ) -> Result + ) -> Result where Events: EventSink + Send, { @@ -96,7 +138,7 @@ impl BridgeLedger { } let next_nonce = expected.checked_add(1).ok_or(BridgeError::NonceOverflow)?; - let record_id = match &tx.payload.operation { + let receipt = match &tx.payload.operation { BridgeOperation::Lock { destination_chain_id, local_asset, @@ -134,11 +176,94 @@ impl BridgeLedger { recipient: recipient.clone(), nonce: tx.payload.nonce, })); - record_id + BridgeReceipt::Locked(record_id) + } + + BridgeOperation::AnchorForeignRoot { + source_chain_id, + view, + state_root, + } => { + let attestor = attestor(&self.store) + .await? + .ok_or(BridgeError::AttestorNotConfigured)?; + if tx.account_id != attestor { + return Err(BridgeError::NotAttestor); + } + // Monotonic per source chain so distinct source chains never block one another. + if let Some(latest) = latest_foreign_view(&self.store, source_chain_id).await? { + if *view <= latest { + return Err(BridgeError::StaleAnchor { + latest, + submitted: *view, + }); + } + } + let root = ForeignRoot { + state_root: *state_root, + }; + put_foreign_root(&mut self.store, source_chain_id, *view, &root); + set_latest_foreign_view(&mut self.store, source_chain_id, *view); + events.emit(foreign_root_anchored_event(ForeignRootAnchored { + source_chain_id: *source_chain_id, + view: *view, + state_root: *state_root, + })); + BridgeReceipt::Anchored { + source_chain_id: *source_chain_id, + view: *view, + } + } + + BridgeOperation::Claim { + source_chain_id, + source_view, + record, + proof, + } => { + // The record must belong to the claimed source chain and target this chain. + if record.source_chain_id != *source_chain_id { + return Err(BridgeError::ClaimSourceMismatch); + } + let local = local_chain_id(&self.store) + .await? + .ok_or(BridgeError::ChainNotConfigured)?; + if record.destination_chain_id != local { + return Err(BridgeError::WrongDestination); + } + // The foreign root for (source_chain_id, source_view) must be anchored. + let anchored = foreign_root(&self.store, source_chain_id, *source_view) + .await? + .ok_or(BridgeError::MissingAnchor)?; + // The proof must authenticate this exact (content-addressed) record under the root. + let record_id = record.record_id(); + let key = transfer_record_key(&record_id); + if !verify_state_update(proof, &anchored.state_root, &key, record.encode().as_ref()) + { + return Err(BridgeError::InvalidProof); + } + // Exactly-once: a record consumed under its source chain can never be claimed again. + if is_consumed(&self.store, source_chain_id, &record_id).await? { + return Err(BridgeError::AlreadyClaimed); + } + mark_consumed(&mut self.store, source_chain_id, &record_id); + events.emit(transfer_claimed_event(TransferClaimed { + record_id, + source_chain_id: *source_chain_id, + source_view: *source_view, + source_asset: record.source_asset, + recipient: record.recipient.clone(), + amount: record.amount, + })); + BridgeReceipt::Claimed { + source_asset: record.source_asset, + recipient: record.recipient.clone(), + amount: record.amount, + } } }; set_bridge_nonce(&mut self.store, &tx.account_id, next_nonce); - Ok(record_id) + Ok(receipt) } } diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs index e29468f..f94d24c 100644 --- a/bridge/src/lib.rs +++ b/bridge/src/lib.rs @@ -10,18 +10,23 @@ pub mod rpc; pub mod record; pub use record::{ - escrow_address, transfer_record, AssetId, BridgeTransferRecord, ChainId, TransferRecordId, - BRIDGE_NAMESPACE, + attestor, escrow_address, foreign_root, is_consumed, mark_consumed, put_foreign_root, + transfer_record, transfer_record_key, AssetId, BridgeTransferRecord, ChainId, ForeignRoot, + TransferRecordId, BRIDGE_NAMESPACE, }; pub mod events; -pub use events::{transfer_locked_event, TransferLocked, TRANSFER_LOCKED_EVENT}; +pub use events::{ + foreign_root_anchored_event, transfer_claimed_event, transfer_locked_event, ForeignRootAnchored, + TransferClaimed, TransferLocked, FOREIGN_ROOT_ANCHORED_EVENT, TRANSFER_CLAIMED_EVENT, + TRANSFER_LOCKED_EVENT, +}; pub mod genesis; pub use genesis::BridgeGenesis; pub mod ledger; -pub use ledger::{BridgeError, BridgeLedger}; +pub use ledger::{BridgeError, BridgeLedger, BridgeReceipt}; pub mod transaction; pub use transaction::{ diff --git a/bridge/src/record.rs b/bridge/src/record.rs index 76ab5c3..9051086 100644 --- a/bridge/src/record.rs +++ b/bridge/src/record.rs @@ -5,9 +5,12 @@ //! consumed exactly once. This module defines the record schema, its content-addressed id, the //! state keys, and minimal read/write accessors. //! -//! Deliberately out of scope here: proof generation/verification, the source-side lock operation, -//! the destination claim operation, the escrow balance table, and coins integration. Those build on -//! this schema in later PRs. +//! This module also holds the destination-side anchoring state (attested foreign roots keyed by +//! `(source_chain_id, view)`) and the consumed-record replay guard the claim uses. +//! +//! Deliberately out of scope here: proof generation/verification (lives in `nunchi-common`), the +//! signed operations themselves (see `transaction`/`ledger`), and the asset movement / settlement, +//! which stays in the chain's coins integration layer so this crate remains decoupled from coins. use bytes::{Buf, BufMut}; use commonware_codec::{ @@ -40,8 +43,12 @@ enum Table { ConsumedRecord = 1, /// Per-sender lock nonce, giving each of a sender's transfers a distinct record id. Nonce = 2, - /// Singleton module configuration (currently just this chain's [`ChainId`]). + /// Singleton module configuration (this chain's [`ChainId`] and the anchor attestor). Config = 3, + /// Attested foreign state roots, keyed by `(source_chain_id, view)`. + ForeignRoot = 4, + /// Latest anchored view per source chain, enforcing monotonic anchoring. + ForeignLatestView = 5, } impl From for u8 { @@ -297,3 +304,119 @@ pub async fn local_chain_id(store: &S) -> Result, pub fn set_local_chain_id(store: &mut S, chain_id: &ChainId) { store.set(local_chain_id_key(), chain_id.encode().as_ref().to_vec()); } + +/// An attested foreign state root: a snapshot of another chain's authenticated state that a +/// destination claim verifies transfer records against. In this MVP it is written by a trusted +/// attestor (see the bridge genesis), not derived from a cryptographic finalization proof. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ForeignRoot { + /// The foreign chain's authenticated state root. + pub state_root: Digest, +} + +impl Write for ForeignRoot { + fn write(&self, buf: &mut impl BufMut) { + self.state_root.write(buf); + } +} + +impl Read for ForeignRoot { + type Cfg = (); + + fn read_cfg(buf: &mut impl Buf, _: &Self::Cfg) -> Result { + Ok(Self { + state_root: Digest::read(buf)?, + }) + } +} + +impl FixedSize for ForeignRoot { + const SIZE: usize = Digest::SIZE; +} + +/// Authenticated-state key for the attested root of `source_chain_id` at `view`. +pub fn foreign_root_key(source_chain_id: &ChainId, view: u64) -> Digest { + let mut logical = source_chain_id.encode().as_ref().to_vec(); + logical.extend_from_slice(view.encode().as_ref()); + NS.key(Table::ForeignRoot, &logical) +} + +/// Stage the attested foreign `root` for `source_chain_id` at `view`. Roots are kept per view (not +/// latest-only) so an in-flight claim built against an earlier root still verifies. +pub fn put_foreign_root( + store: &mut S, + source_chain_id: &ChainId, + view: u64, + root: &ForeignRoot, +) { + store.set( + foreign_root_key(source_chain_id, view), + root.encode().as_ref().to_vec(), + ); +} + +/// Read the attested foreign root for `source_chain_id` at `view`, if anchored. +pub async fn foreign_root( + store: &S, + source_chain_id: &ChainId, + view: u64, +) -> Result, StateError> { + match store.get(&foreign_root_key(source_chain_id, view)).await? { + Some(bytes) => ForeignRoot::decode(bytes.as_ref()) + .map(Some) + .map_err(|err| StateError::Backend(err.to_string())), + None => Ok(None), + } +} + +/// Authenticated-state key for the latest anchored view of `source_chain_id`. +fn latest_foreign_view_key(source_chain_id: &ChainId) -> Digest { + NS.key(Table::ForeignLatestView, source_chain_id.encode().as_ref()) +} + +/// The latest anchored view for `source_chain_id`, if any root has been anchored. Monotonicity is +/// tracked per source chain so different source chains never block one another. +pub async fn latest_foreign_view( + store: &S, + source_chain_id: &ChainId, +) -> Result, StateError> { + match store.get(&latest_foreign_view_key(source_chain_id)).await? { + Some(bytes) => { + u64::decode(bytes.as_ref()).map(Some).map_err(|err| StateError::Backend(err.to_string())) + } + None => Ok(None), + } +} + +/// Stage the latest anchored view for `source_chain_id`. +pub fn set_latest_foreign_view( + store: &mut S, + source_chain_id: &ChainId, + view: u64, +) { + store.set( + latest_foreign_view_key(source_chain_id), + view.encode().as_ref().to_vec(), + ); +} + +/// Authenticated-state key for the configured anchor attestor. +fn attestor_key() -> Digest { + NS.key(Table::Config, b"attestor") +} + +/// The configured anchor attestor, if the chain pins one (see the bridge genesis). Only this account +/// may anchor foreign roots. +pub async fn attestor(store: &S) -> Result, StateError> { + match store.get(&attestor_key()).await? { + Some(bytes) => Address::decode(bytes.as_ref()) + .map(Some) + .map_err(|err| StateError::Backend(err.to_string())), + None => Ok(None), + } +} + +/// Pin the anchor attestor. Written once at genesis. +pub fn set_attestor(store: &mut S, attestor: &Address) { + store.set(attestor_key(), attestor.encode().as_ref().to_vec()); +} diff --git a/bridge/src/tests/claim.rs b/bridge/src/tests/claim.rs new file mode 100644 index 0000000..7c902b4 --- /dev/null +++ b/bridge/src/tests/claim.rs @@ -0,0 +1,466 @@ +use std::num::NonZeroU64; + +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{sha256, Hasher, Sha256}; +use commonware_runtime::{deterministic, Runner as _, Supervisor as _}; +use nunchi_common::{ + state_db::{verify_state_update, CommitState, StateProof}, + Address, NoopEventSink, QmdbState, VecEventSink, +}; +use nunchi_crypto::PrivateKey; + +use crate::events::{TransferClaimed, TRANSFER_CLAIMED_EVENT}; +use crate::genesis::BridgeGenesis; +use crate::ledger::{BridgeError, BridgeLedger, BridgeReceipt}; +use crate::record::{ + is_consumed, put_transfer_record, transfer_record_key, AssetId, BridgeTransferRecord, ChainId, +}; +use crate::transaction::{BridgeOperation, Transaction}; + +fn signer(seed: u64) -> PrivateKey { + PrivateKey::from_seed(seed) +} + +fn addr(key: &PrivateKey) -> Address { + Address::external(&key.public_key()) +} + +fn source_chain() -> ChainId { + ChainId(Sha256::hash(b"source-chain")) +} + +fn dest_chain() -> ChainId { + ChainId(Sha256::hash(b"dest-chain")) +} + +fn sample_record(recipient: Address) -> BridgeTransferRecord { + let source = source_chain(); + BridgeTransferRecord { + source_chain_id: source, + destination_chain_id: dest_chain(), + source_asset: AssetId::derive(&source, &Sha256::hash(b"coin")), + amount: 1_000, + sender: addr(&signer(9)), + recipient, + nonce: 0, + } +} + +/// Write `record` into a fresh source-chain state, commit, and produce an inclusion proof of it +/// against the committed root. Returns `(committed_root, proof)`. +async fn source_root_and_proof( + context: &deterministic::Context, + record: &BridgeTransferRecord, +) -> (sha256::Digest, StateProof) { + let mut source = QmdbState::init(context.child("src"), "bridge-claim-source") + .await + .expect("init source"); + put_transfer_record(&mut source, record); + let root = source.commit().await.expect("commit source"); + let bounds = source.operation_bounds().await; + let proof = source + .proof(bounds.start, NonZeroU64::new(1024).unwrap()) + .await + .expect("proof"); + (root, proof) +} + +async fn dest_ledger( + context: &deterministic::Context, + attestor: &Address, +) -> BridgeLedger> { + let mut dest = QmdbState::init(context.child("dst"), "bridge-claim-dest") + .await + .expect("init dest"); + BridgeGenesis::new(dest_chain()) + .with_attestor(attestor.clone()) + .apply(&mut dest); + BridgeLedger::new(dest) +} + +fn anchor_tx( + attestor: &PrivateKey, + nonce: u64, + source: ChainId, + view: u64, + state_root: sha256::Digest, +) -> Transaction { + Transaction::sign( + attestor, + nonce, + BridgeOperation::AnchorForeignRoot { + source_chain_id: source, + view, + state_root, + }, + ) +} + +fn claim_tx( + claimer: &PrivateKey, + nonce: u64, + source: ChainId, + source_view: u64, + record: BridgeTransferRecord, + proof: StateProof, +) -> Transaction { + Transaction::sign( + claimer, + nonce, + BridgeOperation::Claim { + source_chain_id: source, + source_view, + record, + proof, + }, + ) +} + +#[test] +fn lock_produces_a_claimable_record_proof() { + // The record a real `BridgeOperation::Lock` writes must be provable end to end: commit the + // source state, generate an inclusion proof, and verify it exactly as a destination claim does. + deterministic::Runner::default().start(|context| async move { + let mut source = QmdbState::init(context.child("src"), "lock-proof") + .await + .expect("init source"); + // The source chain's own id is `source_chain()`; the lock stamps it onto the record. + BridgeGenesis::new(source_chain()).apply(&mut source); + + let alice = signer(1); + let recipient = addr(&signer(2)); + let local_asset = Sha256::hash(b"coin"); + let mut ledger = BridgeLedger::new(source); + let lock = Transaction::sign( + &alice, + 0, + BridgeOperation::Lock { + destination_chain_id: dest_chain(), + local_asset, + amount: 1_000, + recipient: recipient.clone(), + }, + ); + let receipt = ledger + .apply_transaction(&lock, NoopEventSink) + .await + .expect("lock"); + let BridgeReceipt::Locked(record_id) = receipt else { + panic!("expected Locked receipt"); + }; + + let mut source = ledger.into_inner(); + let root = source.commit().await.expect("commit source"); + let bounds = source.operation_bounds().await; + let proof = source + .proof(bounds.start, NonZeroU64::new(1024).unwrap()) + .await + .expect("proof"); + + // The record the lock wrote, reconstructed, must be authenticated by the proof. + let record = BridgeTransferRecord { + source_chain_id: source_chain(), + destination_chain_id: dest_chain(), + source_asset: AssetId::derive(&source_chain(), &local_asset), + amount: 1_000, + sender: addr(&alice), + recipient, + nonce: 0, + }; + assert_eq!(record.record_id(), record_id); + assert!( + verify_state_update( + &proof, + &root, + &transfer_record_key(&record_id), + record.encode().as_ref() + ), + "a lock-written record must be provable to a destination claim" + ); + }); +} + +#[test] +fn claim_succeeds_and_marks_consumed() { + deterministic::Runner::default().start(|context| async move { + let recipient = addr(&signer(2)); + let record = sample_record(recipient.clone()); + let (root, proof) = source_root_and_proof(&context, &record).await; + let record_id = record.record_id(); + + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + + // Anchor the source root at view 7. + let mut sink = VecEventSink::new(); + let anchored = ledger + .apply_transaction(&anchor_tx(&attestor, 0, source_chain(), 7, root), &mut sink) + .await + .expect("anchor"); + assert_eq!( + anchored, + BridgeReceipt::Anchored { + source_chain_id: source_chain(), + view: 7, + } + ); + + // Claim against the anchored root. + let claimer = signer(3); + let mut claim_sink = VecEventSink::new(); + let receipt = ledger + .apply_transaction( + &claim_tx(&claimer, 0, source_chain(), 7, record.clone(), proof), + &mut claim_sink, + ) + .await + .expect("claim"); + assert_eq!( + receipt, + BridgeReceipt::Claimed { + source_asset: record.source_asset, + recipient, + amount: record.amount, + } + ); + + // The claim emitted a TransferClaimed event with the settlement details. + assert_eq!(claim_sink.len(), 1); + let event = &claim_sink.events()[0]; + assert_eq!(event.name.as_ref(), TRANSFER_CLAIMED_EVENT); + let decoded = TransferClaimed::decode(event.value.as_ref()).expect("decode event"); + assert_eq!(decoded.record_id, record_id); + assert_eq!(decoded.amount, record.amount); + + // The record is now consumed. + let mut state = ledger.into_inner(); + state.commit().await.expect("commit dest"); + assert!(is_consumed(&state, &source_chain(), &record_id) + .await + .expect("consumed")); + }); +} + +#[test] +fn claim_rejects_double_claim() { + deterministic::Runner::default().start(|context| async move { + let recipient = addr(&signer(2)); + let record = sample_record(recipient); + let (root, proof) = source_root_and_proof(&context, &record).await; + + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + ledger + .apply_transaction(&anchor_tx(&attestor, 0, source_chain(), 7, root), NoopEventSink) + .await + .expect("anchor"); + + let claimer = signer(3); + ledger + .apply_transaction( + &claim_tx(&claimer, 0, source_chain(), 7, record.clone(), proof.clone()), + NoopEventSink, + ) + .await + .expect("first claim"); + + // A second claim (correct next nonce) is rejected as already consumed. + let err = ledger + .apply_transaction( + &claim_tx(&claimer, 1, source_chain(), 7, record, proof), + NoopEventSink, + ) + .await + .expect_err("double claim"); + assert_eq!(err, BridgeError::AlreadyClaimed); + }); +} + +#[test] +fn claim_rejects_missing_anchor() { + deterministic::Runner::default().start(|context| async move { + let recipient = addr(&signer(2)); + let record = sample_record(recipient); + let (_root, proof) = source_root_and_proof(&context, &record).await; + + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + // No anchor for (source_chain, 7). + let claimer = signer(3); + let err = ledger + .apply_transaction( + &claim_tx(&claimer, 0, source_chain(), 7, record, proof), + NoopEventSink, + ) + .await + .expect_err("missing anchor"); + assert_eq!(err, BridgeError::MissingAnchor); + }); +} + +#[test] +fn claim_rejects_wrong_destination() { + deterministic::Runner::default().start(|context| async move { + let recipient = addr(&signer(2)); + // Record destined for a different chain than this ledger's local chain. + let mut record = sample_record(recipient); + record.destination_chain_id = ChainId(Sha256::hash(b"other-dest")); + let (root, proof) = source_root_and_proof(&context, &record).await; + + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + ledger + .apply_transaction(&anchor_tx(&attestor, 0, source_chain(), 7, root), NoopEventSink) + .await + .expect("anchor"); + + let claimer = signer(3); + let err = ledger + .apply_transaction( + &claim_tx(&claimer, 0, source_chain(), 7, record, proof), + NoopEventSink, + ) + .await + .expect_err("wrong destination"); + assert_eq!(err, BridgeError::WrongDestination); + }); +} + +#[test] +fn claim_rejects_tampered_record() { + deterministic::Runner::default().start(|context| async move { + let recipient = addr(&signer(2)); + let record = sample_record(recipient); + let (root, proof) = source_root_and_proof(&context, &record).await; + + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + ledger + .apply_transaction(&anchor_tx(&attestor, 0, source_chain(), 7, root), NoopEventSink) + .await + .expect("anchor"); + + // Tamper the amount: the proof was generated for the original record, so it no longer + // authenticates the (content-addressed) tampered record. + let mut tampered = record; + tampered.amount += 1; + let claimer = signer(3); + let err = ledger + .apply_transaction( + &claim_tx(&claimer, 0, source_chain(), 7, tampered, proof), + NoopEventSink, + ) + .await + .expect_err("tampered record"); + assert_eq!(err, BridgeError::InvalidProof); + }); +} + +#[test] +fn anchor_rejects_non_attestor() { + deterministic::Runner::default().start(|context| async move { + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + + // A different signer tries to anchor. + let impostor = signer(4); + let err = ledger + .apply_transaction( + &anchor_tx(&impostor, 0, source_chain(), 7, Sha256::hash(b"root")), + NoopEventSink, + ) + .await + .expect_err("non-attestor"); + assert_eq!(err, BridgeError::NotAttestor); + }); +} + +#[test] +fn anchor_rejects_stale_view() { + deterministic::Runner::default().start(|context| async move { + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + + ledger + .apply_transaction( + &anchor_tx(&attestor, 0, source_chain(), 7, Sha256::hash(b"root-7")), + NoopEventSink, + ) + .await + .expect("anchor view 7"); + + // Re-anchoring the same view is stale. + let err = ledger + .apply_transaction( + &anchor_tx(&attestor, 1, source_chain(), 7, Sha256::hash(b"root-7b")), + NoopEventSink, + ) + .await + .expect_err("stale view"); + assert_eq!( + err, + BridgeError::StaleAnchor { + latest: 7, + submitted: 7 + } + ); + }); +} + +#[test] +fn anchor_is_monotonic_per_source_chain() { + deterministic::Runner::default().start(|context| async move { + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + let other_source = ChainId(Sha256::hash(b"other-source")); + + // Anchor chain A at a high view. + ledger + .apply_transaction( + &anchor_tx(&attestor, 0, source_chain(), 100, Sha256::hash(b"a-100")), + NoopEventSink, + ) + .await + .expect("anchor A@100"); + + // A different source chain is unaffected: it can anchor at a low view. + let anchored = ledger + .apply_transaction( + &anchor_tx(&attestor, 1, other_source, 1, Sha256::hash(b"b-1")), + NoopEventSink, + ) + .await + .expect("anchor B@1"); + assert_eq!( + anchored, + BridgeReceipt::Anchored { + source_chain_id: other_source, + view: 1, + } + ); + }); +} + +#[test] +fn anchor_and_claim_codec_round_trip() { + let anchor = BridgeOperation::AnchorForeignRoot { + source_chain_id: source_chain(), + view: 42, + state_root: Sha256::hash(b"root"), + }; + assert_eq!( + BridgeOperation::decode(anchor.encode().as_ref()).expect("decode anchor"), + anchor + ); + + // A claim carries an embedded proof; a signed transaction wrapping it must round-trip. + deterministic::Runner::default().start(|context| async move { + let record = sample_record(addr(&signer(2))); + let (_root, proof) = source_root_and_proof(&context, &record).await; + let tx = claim_tx(&signer(3), 0, source_chain(), 7, record, proof); + assert_eq!( + Transaction::decode(tx.encode().as_ref()).expect("decode claim tx"), + tx + ); + }); +} diff --git a/bridge/src/tests/ledger.rs b/bridge/src/tests/ledger.rs index 63e8b15..dbf90af 100644 --- a/bridge/src/tests/ledger.rs +++ b/bridge/src/tests/ledger.rs @@ -8,7 +8,7 @@ use nunchi_crypto::PrivateKey; use crate::events::{TransferLocked, TRANSFER_LOCKED_EVENT}; use crate::genesis::BridgeGenesis; -use crate::ledger::{BridgeError, BridgeLedger}; +use crate::ledger::{BridgeError, BridgeLedger, BridgeReceipt}; use crate::record::{bridge_nonce, transfer_record, AssetId, BridgeTransferRecord, ChainId}; use crate::transaction::{BridgeOperation, Transaction}; @@ -66,20 +66,26 @@ fn lock_writes_record_and_advances_nonce() { // Two identical transfers from the same sender differ only by nonce, and so get distinct // content-addressed record ids. - let id0 = ledger + let BridgeReceipt::Locked(id0) = ledger .apply_transaction( &lock_tx(&alice, 0, dest_chain(), coin(), 1_000, recipient.clone()), NoopEventSink, ) .await - .expect("first lock"); - let id1 = ledger + .expect("first lock") + else { + panic!("expected Locked receipt"); + }; + let BridgeReceipt::Locked(id1) = ledger .apply_transaction( &lock_tx(&alice, 1, dest_chain(), coin(), 1_000, recipient.clone()), NoopEventSink, ) .await - .expect("second lock"); + .expect("second lock") + else { + panic!("expected Locked receipt"); + }; assert_ne!(id0, id1); let expected = BridgeTransferRecord { @@ -118,13 +124,16 @@ fn lock_emits_transfer_locked_event() { let mut ledger = BridgeLedger::new(state); let mut sink = VecEventSink::new(); - let record_id = ledger + let BridgeReceipt::Locked(record_id) = ledger .apply_transaction( &lock_tx(&alice, 0, dest_chain(), coin(), 500, recipient.clone()), &mut sink, ) .await - .expect("lock"); + .expect("lock") + else { + panic!("expected Locked receipt"); + }; assert_eq!(sink.len(), 1); let event = &sink.events()[0]; @@ -247,8 +256,9 @@ fn lock_rejects_bad_signature() { // Tamper the operation after signing so the signature no longer matches. let mut tx = lock_tx(&alice, 0, dest_chain(), coin(), 1_000, addr(&signer(2))); - let BridgeOperation::Lock { amount, .. } = &mut tx.payload.operation; - *amount += 1; + if let BridgeOperation::Lock { amount, .. } = &mut tx.payload.operation { + *amount += 1; + } let err = ledger .apply_transaction(&tx, NoopEventSink) .await diff --git a/bridge/src/tests/mod.rs b/bridge/src/tests/mod.rs index 627ca9a..ba8674a 100644 --- a/bridge/src/tests/mod.rs +++ b/bridge/src/tests/mod.rs @@ -1,3 +1,4 @@ +mod claim; mod genesis; mod ledger; mod record; diff --git a/bridge/src/transaction.rs b/bridge/src/transaction.rs index 67e5721..90331b3 100644 --- a/bridge/src/transaction.rs +++ b/bridge/src/transaction.rs @@ -1,14 +1,19 @@ //! Signed bridge operations. -use crate::record::{ChainId, BRIDGE_NAMESPACE}; -use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use crate::record::{BridgeTransferRecord, ChainId, BRIDGE_NAMESPACE}; +use commonware_codec::{EncodeSize, Error, RangeCfg, Read, ReadExt, Write}; use commonware_cryptography::sha256::Digest; -use nunchi_common::{Address, Operation}; +use nunchi_common::{ + state_db::{StateProof, StateProofCfg}, + Address, Operation, +}; #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeOperationId { Lock = 0, + AnchorForeignRoot = 1, + Claim = 2, } #[derive(Debug, thiserror::Error)] @@ -21,6 +26,8 @@ impl TryFrom for BridgeOperationId { fn try_from(value: u8) -> Result { match value { 0 => Ok(Self::Lock), + 1 => Ok(Self::AnchorForeignRoot), + 2 => Ok(Self::Claim), _ => Err(InvalidBridgeOperationId(value)), } } @@ -42,6 +49,19 @@ impl Read for BridgeOperationId { } } +/// Fixed decoding limits for a claim's embedded [`StateProof`]. +/// +/// A `BridgeOperation` must decode with `Cfg = ()` (the [`Operation`] contract), but a `StateProof` +/// needs explicit allocation bounds. These bridge-internal bounds are applied when reading a claim's +/// proof, so the operation stays self-contained while still capping attacker-controlled allocation. +fn claim_proof_cfg() -> StateProofCfg { + StateProofCfg { + max_proof_digests: 4096, + operations: RangeCfg::new(0..=4096usize), + value_len: RangeCfg::new(0..=65536usize), + } +} + /// A bridge operation authorized by a signed transaction. #[derive(Clone, Debug, Eq, PartialEq)] pub enum BridgeOperation { @@ -58,6 +78,32 @@ pub enum BridgeOperation { /// Destination-chain account to credit. recipient: Address, }, + /// Anchor an attested foreign state root that destination claims verify against. + /// + /// Only the genesis-configured attestor may submit this, and the view must be strictly monotonic + /// per source chain. This is a trusted/authority-attested anchor for the MVP, not a cryptographic + /// finalization proof. + AnchorForeignRoot { + /// The foreign source chain this root belongs to. + source_chain_id: ChainId, + /// Monotonic view/height selecting this root among the source chain's history. + view: u64, + /// The foreign chain's authenticated state root at `view`. + state_root: Digest, + }, + /// Claim a transfer on the destination chain by proving its record against an anchored foreign + /// root; settlement to the recipient happens in the integration layer. + Claim { + /// The source chain the transfer originated on. + source_chain_id: ChainId, + /// The anchored view whose root `proof` verifies against. + source_view: u64, + /// The transfer record being claimed. It is content-addressed, so `proof` authenticates + /// exactly this record and no substitute. + record: BridgeTransferRecord, + /// Inclusion proof that `record` is committed under the anchored foreign root. + proof: StateProof, + }, } impl Write for BridgeOperation { @@ -75,6 +121,28 @@ impl Write for BridgeOperation { amount.write(buf); recipient.write(buf); } + Self::AnchorForeignRoot { + source_chain_id, + view, + state_root, + } => { + BridgeOperationId::AnchorForeignRoot.write(buf); + source_chain_id.write(buf); + view.write(buf); + state_root.write(buf); + } + Self::Claim { + source_chain_id, + source_view, + record, + proof, + } => { + BridgeOperationId::Claim.write(buf); + source_chain_id.write(buf); + source_view.write(buf); + record.write(buf); + proof.write(buf); + } } } } @@ -90,6 +158,19 @@ impl Read for BridgeOperation { amount: u128::read(buf)?, recipient: Address::read(buf)?, }), + BridgeOperationId::AnchorForeignRoot => Ok(Self::AnchorForeignRoot { + source_chain_id: ChainId::read(buf)?, + view: u64::read(buf)?, + state_root: Digest::read(buf)?, + }), + BridgeOperationId::Claim => Ok(Self::Claim { + source_chain_id: ChainId::read(buf)?, + source_view: u64::read(buf)?, + record: BridgeTransferRecord::read(buf)?, + // Decode the embedded proof with fixed bridge-internal bounds so the operation's + // own `Read` stays `Cfg = ()`. + proof: StateProof::read_cfg(buf, &claim_proof_cfg())?, + }), } } } @@ -108,6 +189,22 @@ impl EncodeSize for BridgeOperation { + amount.encode_size() + recipient.encode_size() } + Self::AnchorForeignRoot { + source_chain_id, + view, + state_root, + } => source_chain_id.encode_size() + view.encode_size() + state_root.encode_size(), + Self::Claim { + source_chain_id, + source_view, + record, + proof, + } => { + source_chain_id.encode_size() + + source_view.encode_size() + + record.encode_size() + + proof.encode_size() + } } } } diff --git a/coins/src/ledger.rs b/coins/src/ledger.rs index 889623c..83aa31d 100644 --- a/coins/src/ledger.rs +++ b/coins/src/ledger.rs @@ -170,6 +170,25 @@ impl Ledger { Ok(()) } + /// Mint `amount` of `coin` to `to` for bridge claim settlement, increasing total supply. + /// + /// This is an unauthenticated, supply-increasing primitive intended **only** for the coins-chain + /// bridge integration, invoked after a bridge claim has been verified and marked consumed in the + /// same overlay. It performs no issuer or signature check — the verified, exactly-once claim is + /// the authorization — so it must not be exposed as a general-purpose mint path. On the + /// destination chain the minted asset is the wrapped representation of the source asset, backed + /// 1:1 by the source-chain escrow. + pub async fn bridge_mint( + &mut self, + to: &Address, + coin: CoinId, + amount: u128, + ) -> Result<(), LedgerError> { + self.increase_supply(coin, amount).await?; + self.credit(to, coin, amount).await?; + Ok(()) + } + pub async fn apply_transaction( &mut self, tx: &Transaction, diff --git a/common/src/state_db.rs b/common/src/state_db.rs index 75ecd7b..2fca7fc 100644 --- a/common/src/state_db.rs +++ b/common/src/state_db.rs @@ -261,12 +261,18 @@ impl CommitState for QmdbState { /// against the latest committed root ([`QmdbState::proof`]) or a historical finalized root such as a /// foreign block's state root ([`QmdbState::historical_proof`]). Targeting a single key directly /// (key -> operation location) is still a follow-up, not covered here. +#[derive(Clone, Debug, PartialEq)] pub struct StateProof { proof: Proof, start: Location, operations: Vec, } +// `QmdbOperation` derives only `PartialEq` upstream (not `Eq`), so `StateProof` cannot derive `Eq`. +// Structural equality here is reflexive (no float fields), so `Eq` holds and is implemented manually +// to let a `StateProof` be embedded in types that require `Eq` (for example a bridge claim operation). +impl Eq for StateProof {} + impl StateProof { /// The authenticated operations covered by this proof. pub fn operations(&self) -> &[QmdbOperation] { diff --git a/examples/coins/chain/src/bridge_assets.rs b/examples/coins/chain/src/bridge_assets.rs new file mode 100644 index 0000000..99b1a15 --- /dev/null +++ b/examples/coins/chain/src/bridge_assets.rs @@ -0,0 +1,50 @@ +//! Integration-layer mapping from a bridge [`AssetId`] to a local coins [`CoinId`]. +//! +//! A bridged `AssetId` is a chain-scoped hash and cannot be reversed to a coin, so the destination +//! chain configures, at genesis, which local (wrapped) coin each bridgeable asset mints into. This +//! mapping lives in the coins-chain integration layer, not the bridge crate, so the bridge stays +//! decoupled from coins. + +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::sha256::Digest; +use nunchi_bridge::AssetId; +use nunchi_coins::CoinId; +use nunchi_common::state_db::{Namespace, StateError, StateStore}; + +/// Domain separator for the integration-layer bridge asset mapping. +const NS: Namespace = Namespace::new(b"_NUNCHI_BRIDGE_ASSETS"); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + /// `AssetId` -> local `CoinId` the asset is minted into on claim. + AssetCoin = 0, +} + +impl From
for u8 { + fn from(table: Table) -> Self { + table as Self + } +} + +fn asset_coin_key(asset: &AssetId) -> Digest { + NS.key(Table::AssetCoin, asset.digest().encode().as_ref()) +} + +/// The local coin a bridged `asset` mints into on this chain, if configured. +pub async fn asset_coin( + store: &S, + asset: &AssetId, +) -> Result, StateError> { + match store.get(&asset_coin_key(asset)).await? { + Some(bytes) => CoinId::decode(bytes.as_ref()) + .map(Some) + .map_err(|err| StateError::Backend(err.to_string())), + None => Ok(None), + } +} + +/// Map a bridged `asset` to the local `coin` it mints into. Configured at genesis. +pub fn set_asset_coin(store: &mut S, asset: &AssetId, coin: &CoinId) { + store.set(asset_coin_key(asset), coin.encode().as_ref().to_vec()); +} diff --git a/examples/coins/chain/src/lib.rs b/examples/coins/chain/src/lib.rs index 96891a8..2b99425 100644 --- a/examples/coins/chain/src/lib.rs +++ b/examples/coins/chain/src/lib.rs @@ -16,6 +16,7 @@ use commonware_consensus::types::Epoch; use std::num::NonZeroU64; pub mod application; +pub mod bridge_assets; pub mod engine; pub mod execution; pub mod genesis; diff --git a/examples/coins/chain/src/runtime.rs b/examples/coins/chain/src/runtime.rs index 8e50fad..b682f13 100644 --- a/examples/coins/chain/src/runtime.rs +++ b/examples/coins/chain/src/runtime.rs @@ -2,11 +2,15 @@ use commonware_codec::EncodeSize; use nunchi_authority::{AuthorityError, AuthorityLedger}; -use nunchi_bridge::{escrow_address, BridgeError, BridgeLedger, BridgeOperation}; +use nunchi_bridge::{escrow_address, BridgeError, BridgeLedger, BridgeOperation, BridgeReceipt}; use nunchi_coins::{CoinId, Ledger, LedgerError}; -use nunchi_common::{EventSink, NoopEventSink, Overlay, Runtime, RuntimeContext, StateStore}; +use nunchi_common::{ + state_db::StateError, EventSink, NoopEventSink, Overlay, Runtime, RuntimeContext, StateStore, + VecEventSink, +}; use nunchi_oracle::{OracleError, OracleLedger}; +use crate::bridge_assets::asset_coin; use crate::Transaction; #[derive(Clone, Copy, Debug, Default)] @@ -22,6 +26,16 @@ pub enum RuntimeError { Oracle(#[from] OracleError), #[error("bridge module error: {0}")] Bridge(#[from] BridgeError), + #[error("bridge asset is not mapped to a local coin")] + UnmappedAsset, + #[error("state storage error: {0}")] + Storage(String), +} + +impl From for RuntimeError { + fn from(err: StateError) -> Self { + Self::Storage(err.to_string()) + } } impl RuntimeError { @@ -32,6 +46,7 @@ impl RuntimeError { | Self::Authority(AuthorityError::Storage(_)) | Self::Oracle(OracleError::Storage(_)) | Self::Bridge(BridgeError::Storage(_)) + | Self::Storage(_) ) } } @@ -103,18 +118,20 @@ where ledger.apply_transaction(transaction, context).await?; } Transaction::Bridge(transaction) => { - // Escrow the locked source coins and record the transfer atomically. Both writes go - // into an inner overlay that is committed only if the whole lock succeeds, so a bridge - // validation failure after the escrow move (bad nonce, unconfigured chain, self-bridge, - // unsupported authorization, ...) reverts everything, without relying on the caller to - // discard partial writes. The bridge crate itself stays decoupled from coins. + // Lock, anchor, and claim all commit into one inner overlay so their bridge-state + // effects and the matching coins settlement are atomic: a failure at any step reverts + // everything, without relying on the caller to discard partial writes. The bridge crate + // stays decoupled from coins; escrow (on lock) and mint (on claim) happen here. let mut overlay = Overlay::new(&mut *state); + + // Preconditions before any bridge-state change. match &transaction.payload.operation { BridgeOperation::Lock { local_asset, amount, .. } => { + // Move the locked coins into the bridge escrow. let mut coins = Ledger::new(&mut overlay); coins .transfer( @@ -125,10 +142,43 @@ where ) .await?; } + BridgeOperation::Claim { record, .. } => { + // The mapped destination coin must exist before we consume the record or mint. + if asset_coin(&overlay, &record.source_asset).await?.is_none() { + return Err(RuntimeError::UnmappedAsset); + } + } + BridgeOperation::AnchorForeignRoot { .. } => {} } - let mut ledger = BridgeLedger::new(&mut overlay); - ledger.apply_transaction(transaction, events).await?; + + // Apply the bridge operation (verify, replay guard, consume, ...). Buffer its events + // locally so they are emitted only if the whole operation — including settlement — + // succeeds; a later settlement failure reverts state and drops these events with it. + let mut bridge_events = VecEventSink::new(); + let receipt = { + let mut ledger = BridgeLedger::new(&mut overlay); + ledger.apply_transaction(transaction, &mut bridge_events).await? + }; + + // Settle a successful claim by minting the mapped asset to the recipient. + if let BridgeReceipt::Claimed { + source_asset, + recipient, + amount, + } = receipt + { + let coin = asset_coin(&overlay, &source_asset) + .await? + .ok_or(RuntimeError::UnmappedAsset)?; + let mut coins = Ledger::new(&mut overlay); + coins.bridge_mint(&recipient, coin, amount).await?; + } + overlay.commit(); + // Now that state has committed, forward the buffered bridge events. + for event in bridge_events.into_events() { + events.emit(event); + } } } Ok(()) diff --git a/examples/coins/chain/src/tests/bridge_claim.rs b/examples/coins/chain/src/tests/bridge_claim.rs new file mode 100644 index 0000000..44f57e3 --- /dev/null +++ b/examples/coins/chain/src/tests/bridge_claim.rs @@ -0,0 +1,356 @@ +use std::num::NonZeroU64; + +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{Hasher, Sha256}; +use commonware_runtime::{deterministic, Runner as _, Supervisor as _}; +use nunchi_bridge::{ + record::put_transfer_record, AssetId, BridgeGenesis, BridgeOperation, BridgeTransaction, + BridgeTransferRecord, ChainId, +}; +use nunchi_coins::{CoinOperation, CoinSpec, Ledger, TokenCreated, TokenName, TokenSymbol}; +use nunchi_common::{ + state_db::CommitState, Address, QmdbState, Runtime, RuntimeContext, VecEventSink, +}; +use nunchi_crypto::PrivateKey; + +use crate::bridge_assets::set_asset_coin; +use crate::runtime::{CoinsRuntime, RuntimeError}; +use crate::Transaction; + +fn key(seed: u64) -> PrivateKey { + PrivateKey::ed25519_from_seed(seed) +} + +fn addr(seed: u64) -> Address { + Address::external(&key(seed).public_key()) +} + +fn source_chain() -> ChainId { + ChainId(Sha256::hash(b"source-chain")) +} + +fn dest_chain() -> ChainId { + ChainId(Sha256::hash(b"dest-chain")) +} + +fn spec(symbol: &str, name: &str, supply: u128) -> CoinSpec { + CoinSpec::new( + TokenSymbol::new(symbol).unwrap(), + TokenName::new(name).unwrap(), + 9, + supply, + None, + ) +} + +#[test] +fn bridge_claim_verifies_proof_and_mints_mapped_asset() { + deterministic::Runner::default().start(|context| async move { + let recipient = addr(2); + + // ----- Source chain: write the transfer record and prove it. ----- + let mut source = QmdbState::init(context.child("src"), "claim-src") + .await + .unwrap(); + let src_local_asset = Sha256::hash(b"src-coin"); + let record = BridgeTransferRecord { + source_chain_id: source_chain(), + destination_chain_id: dest_chain(), + source_asset: AssetId::derive(&source_chain(), &src_local_asset), + amount: 400, + sender: addr(1), + recipient: recipient.clone(), + nonce: 0, + }; + put_transfer_record(&mut source, &record); + let source_root = source.commit().await.unwrap(); + let bounds = source.operation_bounds().await; + let proof = source + .proof(bounds.start, NonZeroU64::new(1024).unwrap()) + .await + .unwrap(); + + // Local sanity: the proof authenticates the record against the source root. + assert!( + nunchi_common::state_db::verify_state_update( + &proof, + &source_root, + &nunchi_bridge::transfer_record_key(&record.record_id()), + record.encode().as_ref(), + ), + "proof should authenticate the record locally" + ); + + // ----- Destination chain: wrapped token, mapping, attestor; anchor then claim. ----- + let mut dest = QmdbState::init(context.child("dst"), "claim-dst") + .await + .unwrap(); + let issuer = key(5); + let mut ev = VecEventSink::new(); + // The wrapped destination coin starts at 0 supply; bridge_mint grows it. + let create_wrapped = Transaction::from(nunchi_coins::Transaction::sign( + &issuer, + 0, + CoinOperation::CreateToken { + spec: spec("WSC", "Wrapped Source", 0), + }, + )); + CoinsRuntime::apply(&mut dest, RuntimeContext::default(), &create_wrapped, &mut ev) + .await + .unwrap(); + let wrapped = TokenCreated::decode(ev.events()[0].value.as_ref()) + .unwrap() + .token + .id; + + let attestor = key(3); + BridgeGenesis::new(dest_chain()) + .with_attestor(Address::external(&attestor.public_key())) + .apply(&mut dest); + set_asset_coin(&mut dest, &record.source_asset, &wrapped); + + let anchor = Transaction::from(BridgeTransaction::sign( + &attestor, + 0, + BridgeOperation::AnchorForeignRoot { + source_chain_id: source_chain(), + view: 7, + state_root: source_root, + }, + )); + CoinsRuntime::apply( + &mut dest, + RuntimeContext::default(), + &anchor, + &mut VecEventSink::new(), + ) + .await + .unwrap(); + + let claimer = key(4); + let claim = Transaction::from(BridgeTransaction::sign( + &claimer, + 0, + BridgeOperation::Claim { + source_chain_id: source_chain(), + source_view: 7, + record, + proof, + }, + )); + CoinsRuntime::apply( + &mut dest, + RuntimeContext::default(), + &claim, + &mut VecEventSink::new(), + ) + .await + .unwrap(); + + // The recipient was minted 400 of the wrapped coin on the destination chain, and the + // wrapped token's supply grew to match (backed 1:1 by the source escrow, on the source + // chain, which the destination claim never touches). + let recipient_bal = { + let ledger = Ledger::new(&mut dest); + ledger.balance(&recipient, &wrapped).await.unwrap() + }; + assert_eq!(recipient_bal, 400); + }); +} + +#[test] +fn bridge_claim_rejects_unmapped_asset_without_minting() { + deterministic::Runner::default().start(|context| async move { + let recipient = addr(2); + let record = BridgeTransferRecord { + source_chain_id: source_chain(), + destination_chain_id: dest_chain(), + source_asset: AssetId::derive(&source_chain(), &Sha256::hash(b"coin")), + amount: 400, + sender: addr(9), + recipient: recipient.clone(), + nonce: 0, + }; + + // Minimal source: write the record directly and prove it. + let mut source = QmdbState::init(context.child("src"), "unmapped-src") + .await + .unwrap(); + put_transfer_record(&mut source, &record); + let source_root = source.commit().await.unwrap(); + let bounds = source.operation_bounds().await; + let proof = source + .proof(bounds.start, NonZeroU64::new(1024).unwrap()) + .await + .unwrap(); + + // Destination has an attestor and an anchor, but no asset mapping. + let mut dest = QmdbState::init(context.child("dst"), "unmapped-dst") + .await + .unwrap(); + let attestor = key(3); + BridgeGenesis::new(dest_chain()) + .with_attestor(Address::external(&attestor.public_key())) + .apply(&mut dest); + let anchor = Transaction::from(BridgeTransaction::sign( + &attestor, + 0, + BridgeOperation::AnchorForeignRoot { + source_chain_id: source_chain(), + view: 7, + state_root: source_root, + }, + )); + CoinsRuntime::apply( + &mut dest, + RuntimeContext::default(), + &anchor, + &mut VecEventSink::new(), + ) + .await + .unwrap(); + + // Claim fails because the asset is unmapped; nothing is consumed or minted. + let claimer = key(4); + let claim = Transaction::from(BridgeTransaction::sign( + &claimer, + 0, + BridgeOperation::Claim { + source_chain_id: source_chain(), + source_view: 7, + record: record.clone(), + proof, + }, + )); + let err = CoinsRuntime::apply( + &mut dest, + RuntimeContext::default(), + &claim, + &mut VecEventSink::new(), + ) + .await + .expect_err("unmapped asset"); + assert!(matches!(err, RuntimeError::UnmappedAsset)); + + // The record was not consumed, so a later (mapped) claim could still succeed. + assert!( + !nunchi_bridge::is_consumed(&dest, &source_chain(), &record.record_id()) + .await + .unwrap() + ); + }); +} + +#[test] +fn bridge_claim_settlement_failure_leaves_no_event_or_consumption() { + // If minting fails after the claim verifies, the whole operation reverts: no TransferClaimed + // event escapes to the sink and the record is not marked consumed. + deterministic::Runner::default().start(|context| async move { + let recipient = addr(2); + let record = BridgeTransferRecord { + source_chain_id: source_chain(), + destination_chain_id: dest_chain(), + source_asset: AssetId::derive(&source_chain(), &Sha256::hash(b"coin")), + amount: 400, + sender: addr(9), + recipient, + nonce: 0, + }; + + let mut source = QmdbState::init(context.child("src"), "settle-fail-src") + .await + .unwrap(); + put_transfer_record(&mut source, &record); + let source_root = source.commit().await.unwrap(); + let bounds = source.operation_bounds().await; + let proof = source + .proof(bounds.start, NonZeroU64::new(1024).unwrap()) + .await + .unwrap(); + + // Destination maps the asset to a coin capped below the transfer amount, so bridge_mint + // overflows the max supply. + let mut dest = QmdbState::init(context.child("dst"), "settle-fail-dst") + .await + .unwrap(); + let issuer = key(5); + let mut ev = VecEventSink::new(); + let create_capped = Transaction::from(nunchi_coins::Transaction::sign( + &issuer, + 0, + CoinOperation::CreateToken { + spec: CoinSpec::new( + TokenSymbol::new("CAP").unwrap(), + TokenName::new("Capped").unwrap(), + 9, + 0, + Some(100), + ), + }, + )); + CoinsRuntime::apply(&mut dest, RuntimeContext::default(), &create_capped, &mut ev) + .await + .unwrap(); + let capped = TokenCreated::decode(ev.events()[0].value.as_ref()) + .unwrap() + .token + .id; + + let attestor = key(3); + BridgeGenesis::new(dest_chain()) + .with_attestor(Address::external(&attestor.public_key())) + .apply(&mut dest); + set_asset_coin(&mut dest, &record.source_asset, &capped); + let anchor = Transaction::from(BridgeTransaction::sign( + &attestor, + 0, + BridgeOperation::AnchorForeignRoot { + source_chain_id: source_chain(), + view: 7, + state_root: source_root, + }, + )); + CoinsRuntime::apply( + &mut dest, + RuntimeContext::default(), + &anchor, + &mut VecEventSink::new(), + ) + .await + .unwrap(); + + // The claim verifies, but minting 400 exceeds the cap of 100 -> the whole op reverts. + let claimer = key(4); + let claim = Transaction::from(BridgeTransaction::sign( + &claimer, + 0, + BridgeOperation::Claim { + source_chain_id: source_chain(), + source_view: 7, + record: record.clone(), + proof, + }, + )); + let mut claim_events = VecEventSink::new(); + let err = CoinsRuntime::apply( + &mut dest, + RuntimeContext::default(), + &claim, + &mut claim_events, + ) + .await + .expect_err("mint should overflow the cap"); + assert!(matches!( + err, + RuntimeError::Coins(nunchi_coins::LedgerError::MaxSupplyExceeded { .. }) + )); + + // No event escaped, and the record was not consumed. + assert!(claim_events.is_empty(), "no event should escape a failed claim"); + assert!( + !nunchi_bridge::is_consumed(&dest, &source_chain(), &record.record_id()) + .await + .unwrap() + ); + }); +} diff --git a/examples/coins/chain/src/tests/mod.rs b/examples/coins/chain/src/tests/mod.rs index e5da28e..357aa21 100644 --- a/examples/coins/chain/src/tests/mod.rs +++ b/examples/coins/chain/src/tests/mod.rs @@ -1,4 +1,5 @@ mod application; +mod bridge_claim; mod genesis; mod runtime; mod testnet; From 7147142dd9a524faefad9345e5c2f8fd04025a6b Mon Sep 17 00:00:00 2001 From: Eren Yegit <115787683+erenyegit@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:33:26 +0300 Subject: [PATCH 2/2] test(bridge): cover attestor/claim-mismatch paths and anchor state accessors Adds focused tests for logic branches and state accessors introduced by the destination claim flow: - anchor rejected when no attestor is configured - claim rejected on source-chain mismatch - ForeignRootAnchored event round-trips (topic + fields) - foreign-root, latest-view, and attestor accessors round-trip through state --- bridge/src/tests/claim.rs | 77 +++++++++++++++++++++++++++++++++++++- bridge/src/tests/record.rs | 45 +++++++++++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/bridge/src/tests/claim.rs b/bridge/src/tests/claim.rs index 7c902b4..fce047b 100644 --- a/bridge/src/tests/claim.rs +++ b/bridge/src/tests/claim.rs @@ -9,7 +9,9 @@ use nunchi_common::{ }; use nunchi_crypto::PrivateKey; -use crate::events::{TransferClaimed, TRANSFER_CLAIMED_EVENT}; +use crate::events::{ + ForeignRootAnchored, TransferClaimed, FOREIGN_ROOT_ANCHORED_EVENT, TRANSFER_CLAIMED_EVENT, +}; use crate::genesis::BridgeGenesis; use crate::ledger::{BridgeError, BridgeLedger, BridgeReceipt}; use crate::record::{ @@ -441,6 +443,79 @@ fn anchor_is_monotonic_per_source_chain() { }); } +#[test] +fn anchor_rejects_when_attestor_not_configured() { + deterministic::Runner::default().start(|context| async move { + // Destination has a chain id but no configured attestor. + let mut dest = QmdbState::init(context.child("dst"), "no-attestor") + .await + .expect("init dest"); + BridgeGenesis::new(dest_chain()).apply(&mut dest); + let mut ledger = BridgeLedger::new(dest); + + let attestor = signer(1); + let err = ledger + .apply_transaction( + &anchor_tx(&attestor, 0, source_chain(), 7, Sha256::hash(b"root")), + NoopEventSink, + ) + .await + .expect_err("attestor not configured"); + assert_eq!(err, BridgeError::AttestorNotConfigured); + }); +} + +#[test] +fn claim_rejects_source_chain_mismatch() { + deterministic::Runner::default().start(|context| async move { + let recipient = addr(&signer(2)); + let record = sample_record(recipient); + let (root, proof) = source_root_and_proof(&context, &record).await; + + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + ledger + .apply_transaction(&anchor_tx(&attestor, 0, source_chain(), 7, root), NoopEventSink) + .await + .expect("anchor"); + + // The claim declares a different source chain than the record carries. + let other_source = ChainId(Sha256::hash(b"other-source")); + let claimer = signer(3); + let err = ledger + .apply_transaction( + &claim_tx(&claimer, 0, other_source, 7, record, proof), + NoopEventSink, + ) + .await + .expect_err("source mismatch"); + assert_eq!(err, BridgeError::ClaimSourceMismatch); + }); +} + +#[test] +fn anchor_emits_foreign_root_anchored_event() { + deterministic::Runner::default().start(|context| async move { + let attestor = signer(1); + let mut ledger = dest_ledger(&context, &addr(&attestor)).await; + + let root = Sha256::hash(b"anchored-root"); + let mut sink = VecEventSink::new(); + ledger + .apply_transaction(&anchor_tx(&attestor, 0, source_chain(), 9, root), &mut sink) + .await + .expect("anchor"); + + assert_eq!(sink.len(), 1); + let event = &sink.events()[0]; + assert_eq!(event.name.as_ref(), FOREIGN_ROOT_ANCHORED_EVENT); + let decoded = ForeignRootAnchored::decode(event.value.as_ref()).expect("decode event"); + assert_eq!(decoded.source_chain_id, source_chain()); + assert_eq!(decoded.view, 9); + assert_eq!(decoded.state_root, root); + }); +} + #[test] fn anchor_and_claim_codec_round_trip() { let anchor = BridgeOperation::AnchorForeignRoot { diff --git a/bridge/src/tests/record.rs b/bridge/src/tests/record.rs index 072d7c0..99fcb83 100644 --- a/bridge/src/tests/record.rs +++ b/bridge/src/tests/record.rs @@ -5,8 +5,9 @@ use nunchi_common::{state_db::CommitState, Address, QmdbState}; use nunchi_crypto::PrivateKey; use crate::record::{ - consumed_record_key, is_consumed, mark_consumed, put_transfer_record, transfer_record, - transfer_record_key, AssetId, BridgeTransferRecord, ChainId, TransferRecordId, + attestor, consumed_record_key, foreign_root, is_consumed, latest_foreign_view, mark_consumed, + put_foreign_root, put_transfer_record, set_attestor, set_latest_foreign_view, transfer_record, + transfer_record_key, AssetId, BridgeTransferRecord, ChainId, ForeignRoot, TransferRecordId, }; fn addr(seed: u64) -> Address { @@ -187,3 +188,43 @@ fn consumed_marker_is_set_and_checked() { assert!(!is_consumed(&state, &chain, &other).await.expect("read")); }); } + +#[test] +fn foreign_root_and_config_accessors_roundtrip() { + deterministic::Runner::default().start(|context| async move { + let mut state = QmdbState::init(context, "bridge-anchor-state-test") + .await + .expect("init state"); + + let source = ChainId(Sha256::hash(b"foreign-chain")); + let other = ChainId(Sha256::hash(b"other-chain")); + + // Absent until anchored/configured. + assert_eq!(foreign_root(&state, &source, 5).await.expect("read"), None); + assert_eq!(latest_foreign_view(&state, &source).await.expect("read"), None); + assert_eq!(attestor(&state).await.expect("read"), None); + + let root = ForeignRoot { + state_root: Sha256::hash(b"root-5"), + }; + put_foreign_root(&mut state, &source, 5, &root); + set_latest_foreign_view(&mut state, &source, 5); + let signer = addr(1); + set_attestor(&mut state, &signer); + state.commit().await.expect("commit"); + + // Everything reads back, scoped by (source chain, view). + assert_eq!( + foreign_root(&state, &source, 5).await.expect("read"), + Some(root) + ); + assert_eq!(foreign_root(&state, &source, 6).await.expect("read"), None); + assert_eq!(foreign_root(&state, &other, 5).await.expect("read"), None); + assert_eq!( + latest_foreign_view(&state, &source).await.expect("read"), + Some(5) + ); + assert_eq!(latest_foreign_view(&state, &other).await.expect("read"), None); + assert_eq!(attestor(&state).await.expect("read"), Some(signer)); + }); +}