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.
This commit is contained in:
moudyellaz
2026-08-07 14:56:55 +02:00
parent 5cbd821261
commit 69a5aa7899
4 changed files with 110 additions and 52 deletions
@@ -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<u8> {
mint_payload_of(LOCK_AMOUNT)
}
fn mint_payload_of(amount: u128) -> Vec<u8> {
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<ValidatedStateDiff, lee::error::LeeError> {
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(),
);
+9 -1
View File
@@ -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)
@@ -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";
+7 -2
View File
@@ -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");