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..fce047b --- /dev/null +++ b/bridge/src/tests/claim.rs @@ -0,0 +1,541 @@ +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::{ + ForeignRootAnchored, TransferClaimed, FOREIGN_ROOT_ANCHORED_EVENT, 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_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 { + 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/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)); + }); +} 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;