From 69a5aa7899039385dbff086f282e00bf064945f3 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Fri, 7 Aug 2026 14:56:55 +0200 Subject: [PATCH] fix(cross-zone)!: cap a single wrapped-token mint Closes #678. BREAKING CHANGE: `wrapped_token` and `bridge_lock` image ids move, relocating the wrapped-token config and every holding PDA. A lock above the cap is now refused at the source rather than escrowing balance the destination will not mint. --- .../tests/cross_zone_state_machine.rs | 125 +++++++++++------- lez/programs/bridge_lock/src/main.rs | 10 +- lez/programs/wrapped_token/core/src/lib.rs | 18 +++ lez/programs/wrapped_token/src/main.rs | 9 +- 4 files changed, 110 insertions(+), 52 deletions(-) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 94488f409..6d0b64e09 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -97,14 +97,86 @@ fn seed_wrapped_config(state: &mut V03State) { /// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone /// payload (risc0 words, little-endian bytes). fn mint_payload() -> Vec { + mint_payload_of(LOCK_AMOUNT) +} + +fn mint_payload_of(amount: u128) -> Vec { let mint = wrapped_token_core::Instruction::Mint { recipient: RECIPIENT, - amount: LOCK_AMOUNT, + amount, }; let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint"); words.iter().flat_map(|word| word.to_le_bytes()).collect() } +/// Runs a bridge mint of `amount` through the inbox, as the watcher would. +fn dispatch_mint(amount: u128) -> Result { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + let mut state = base_state(); + seed_inbox_config( + &mut state, + self_zone, + src_zone, + [9_u32; 8], + wrapped_token_id, + ); + seed_wrapped_config(&mut state); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_block_hash: SRC_BLOCK_HASH, + src_tx_index: 0, + src_program_id: [9_u32; 8], + target_program_id: wrapped_token_id, + payload: mint_payload_of(amount), + l1_inclusion_witness: None, + }; + + let message = Message::try_new( + inbox_id, + vec![ + inbox_config_account_id(inbox_id), + inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id), + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) +} + +/// One message must not be able to pin a holding near `u128::MAX`, which would +/// make every later honest mint to that recipient overflow and fail for good. +#[test] +fn a_mint_above_the_cap_is_rejected() { + assert!( + dispatch_mint(wrapped_token_core::MAX_MINT_AMOUNT + 1).is_err(), + "an amount over the per-mint cap must not execute" + ); +} + +#[test] +fn a_mint_at_the_cap_is_accepted() { + let diff = dispatch_mint(wrapped_token_core::MAX_MINT_AMOUNT) + .expect("the cap itself is a legitimate amount"); + let holding_id = + wrapped_token_core::holding_account_id(programs::wrapped_token().id(), &RECIPIENT); + let minted = wrapped_token_core::read_balance( + &diff.public_diff()[&holding_id].data.clone().into_inner(), + ); + assert_eq!(minted, wrapped_token_core::MAX_MINT_AMOUNT); +} + /// Drives `cross_zone_inbox::Dispatch` directly through the state machine /// (no watcher) and asserts the message is delivered to `ping_receiver`, which /// records the payload into its own PDA. @@ -245,54 +317,9 @@ fn lock_escrows_balance_and_emits_to_outbox() { /// and asserts it chains into `wrapped_token::Mint`, crediting the recipient. #[test] fn inbox_dispatch_mints_wrapped_token() { - let inbox_id = programs::cross_zone_inbox().id(); - let wrapped_token_id = programs::wrapped_token().id(); - - let self_zone = [1_u8; 32]; - let src_zone = [2_u8; 32]; - let src_block_id = 5; - - let mut state = base_state(); - seed_inbox_config( - &mut state, - self_zone, - src_zone, - [9_u32; 8], - wrapped_token_id, - ); - seed_wrapped_config(&mut state); - - let msg = CrossZoneMessage { - src_zone, - src_block_id, - src_block_hash: SRC_BLOCK_HASH, - src_tx_index: 0, - src_program_id: [9_u32; 8], - target_program_id: wrapped_token_id, - payload: mint_payload(), - l1_inclusion_witness: None, - }; - - let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); - let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); - let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); - - let message = Message::try_new( - inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], - vec![], - InboxInstruction::Dispatch(msg), - ) - .expect("build dispatch message"); - let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); - - let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) - .expect("dispatch must validate and execute"); + let diff = dispatch_mint(LOCK_AMOUNT).expect("dispatch must validate and execute"); + let holding_id = + wrapped_token_core::holding_account_id(programs::wrapped_token().id(), &RECIPIENT); let minted = wrapped_token_core::read_balance( &diff.public_diff()[&holding_id].data.clone().into_inner(), ); diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index 8b176ee56..58d173516 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -4,7 +4,7 @@ use lee_core::{ account::AccountWithMetadata, program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; -use wrapped_token_core::Instruction as WrappedInstruction; +use wrapped_token_core::{Instruction as WrappedInstruction, MAX_MINT_AMOUNT}; fn main() { let ( @@ -44,6 +44,14 @@ fn main() { mint_amount, amount, "locked amount must equal the wrapped mint amount" ); + // Refused here rather than on the destination, where the mint would fail + // after this side had already escrowed the balance. Nothing releases an + // escrow, so an amount the destination will not mint has to fail before the + // debit, in the submitter's own transaction where they can see it. + assert!( + amount <= MAX_MINT_AMOUNT, + "locked amount exceeds what the wrapped token will mint" + ); // pre_states: [holder holding (authorized), escrow PDA, outbox PDA]. let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states) diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index a95a5ca0a..8fa525f11 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -8,6 +8,24 @@ use lee_core::{ }; use serde::{Deserialize, Serialize}; +/// The most one mint may credit. +/// +/// The amount is chosen on the peer zone and the balance is a `u128`, so without +/// a bound a single delivery can push a holding to within a hair of the maximum. +/// Every honest mint to that recipient then overflows, and an overflow is a guest +/// panic, so each one fails execution and is eventually given up on. The holding +/// is unusable for inbound transfers for good, at a cost to the attacker of one +/// message. +/// +/// A cap does not put the maximum out of reach, it makes reaching it cost 2^64 +/// deliveries rather than one. +/// +/// `u64::MAX` is a bound the bridge imposes rather than one native balances +/// already obey: `Balance` is a `u128` and the faucet is seeded at its maximum, +/// so a larger amount is representable. `bridge_lock` refuses one at the source +/// so it fails in the submitter's transaction rather than after escrowing. +pub const MAX_MINT_AMOUNT: u128 = 0xFFFF_FFFF_FFFF_FFFF; + const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000"; diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index 19095e393..88cb17887 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -3,8 +3,8 @@ use lee_core::{ program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; use wrapped_token_core::{ - Instruction, balance_bytes, config_account_id, config_seed, holding_account_id, holding_seed, - minter_bytes, read_balance, read_minter, + Instruction, MAX_MINT_AMOUNT, balance_bytes, config_account_id, config_seed, + holding_account_id, holding_seed, minter_bytes, read_balance, read_minter, }; fn main() { @@ -70,6 +70,11 @@ fn mint( "second account must be the recipient holding PDA" ); + assert!( + amount <= MAX_MINT_AMOUNT, + "mint amount exceeds the per-mint cap" + ); + // The backstop against accumulation, which the per-mint cap does not bound. let new_balance = read_balance(&holding.account.data.clone().into_inner()) .checked_add(amount) .expect("wrapped-token balance overflow");