Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions bridge/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<Self, Error> {
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<Self, Error> {
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(),
)
}
32 changes: 24 additions & 8 deletions bridge/src/genesis.rs
Original file line number Diff line number Diff line change
@@ -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<Address>,
}

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<S: StateStore>(&self, store: &mut S) {
set_local_chain_id(store, &self.local_chain_id);
if let Some(attestor) = &self.attestor {
set_attestor(store, attestor);
}
}
}
159 changes: 142 additions & 17 deletions bridge/src/ledger.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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),
}
Expand All @@ -42,6 +67,24 @@ impl From<StateError> 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<S> {
store: S,
Expand All @@ -63,16 +106,15 @@ impl<S: StateStore> BridgeLedger<S> {
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<Events>(
&mut self,
tx: &Transaction,
mut events: Events,
) -> Result<TransferRecordId, BridgeError>
) -> Result<BridgeReceipt, BridgeError>
where
Events: EventSink + Send,
{
Expand All @@ -96,7 +138,7 @@ impl<S: StateStore> BridgeLedger<S> {
}
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,
Expand Down Expand Up @@ -134,11 +176,94 @@ impl<S: StateStore> BridgeLedger<S> {
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)
}
}
Loading
Loading