diff --git a/Cargo.lock b/Cargo.lock index 7ddd3458..b470cdb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1952,6 +1952,8 @@ dependencies = [ "ping_core", "programs", "risc0-zkvm", + "serde", + "wrapped_token_core", ] [[package]] @@ -10282,7 +10284,6 @@ dependencies = [ "programs", "serde", "system_accounts", - "wrapped_token_core", ] [[package]] diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index f57e7d9c..f3485d6f 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index 7cd5a775..0239df93 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index 2f3f4abe..4b9d02a0 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index bf395101..fe07aee7 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -12,6 +12,7 @@ use log::info; use sequencer_service_rpc::RpcClient as _; pub use test_fixtures::*; use wallet::{ + AccountIdentity, cli::{ CliAccountMention, Command, SubcommandReturnValue, account::{AccountSubcommand, NewSubcommand}, @@ -19,6 +20,7 @@ use wallet::{ native_token_transfer::AuthTransferSubcommand, token::TokenProgramAgnosticSubcommand, }, }, + program_facades::{native_token_transfer::NativeTokenTransfer, token::Token}, storage::key_chain::FoundPrivateAccount, }; @@ -68,6 +70,34 @@ pub async fn send( Ok(()) } +/// Like [`send`], but for a `to` that is still a fresh, unclaimed account. +/// +/// The wallet CLI's `AuthTransfer::Send` never signs with the recipient's key (by design: the +/// sender's wallet must not sign on behalf of an account it doesn't own). But claiming a fresh +/// account is only possible if that account's own key signs the transaction, so this bypasses +/// the CLI and calls the program facade directly with an explicit `AccountIdentity::Public` for +/// the recipient, using the key the test wallet holds for the account it just created. +/// +/// Unlike `send`, this doesn't go through the CLI's own poll-until-included step, so it waits +/// for block creation itself before returning. +pub async fn send_claiming_new_account( + ctx: &mut TestContext, + from: AccountId, + to: AccountId, + amount: u128, +) -> Result<()> { + NativeTokenTransfer(ctx.wallet()) + .send_public_transfer( + AccountIdentity::Public(from), + AccountIdentity::Public(to), + amount, + ) + .await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + /// Create a token (New) and wait for the block to be included. pub async fn create_token( ctx: &mut TestContext, @@ -110,6 +140,26 @@ pub async fn token_send( Ok(()) } +/// Like [`token_send`], but for a `to` that is still a fresh, unclaimed holding account. See +/// [`send_claiming_new_account`] for why the CLI can't be used here. +pub async fn token_send_claiming_new_account( + ctx: &mut TestContext, + from: AccountId, + to: AccountId, + amount: u128, +) -> Result<()> { + Token(ctx.wallet()) + .send_transfer_transaction( + AccountIdentity::Public(from), + AccountIdentity::Public(to), + amount, + ) + .await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + /// Retrieve the native token balance for `account_id`. pub async fn account_balance(ctx: &TestContext, account_id: AccountId) -> Result { Ok(ctx diff --git a/integration_tests/tests/amm.rs b/integration_tests/tests/amm.rs index 6e270413..894d787c 100644 --- a/integration_tests/tests/amm.rs +++ b/integration_tests/tests/amm.rs @@ -9,7 +9,7 @@ use std::time::Duration; use anyhow::Result; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, create_token, get_account, new_account, - public_mention, token_send, + public_mention, token_send_claiming_new_account, }; use log::info; use tokio::test; @@ -54,14 +54,11 @@ async fn amm_public() -> Result<()> { ) .await?; - // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_1` - token_send( - &mut ctx, - public_mention(supply_account_id_1), - public_mention(recipient_account_id_1), - 7, - ) - .await?; + // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_1`. + // `recipient_account_id_1` is still unclaimed, so this bypasses the wallet CLI (which never + // signs with the recipient's key) and signs with the recipient's own key directly. + token_send_claiming_new_account(&mut ctx, supply_account_id_1, recipient_account_id_1, 7) + .await?; // Create new token create_token( @@ -73,14 +70,10 @@ async fn amm_public() -> Result<()> { ) .await?; - // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_2` - token_send( - &mut ctx, - public_mention(supply_account_id_2), - public_mention(recipient_account_id_2), - 7, - ) - .await?; + // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_2`. + // `recipient_account_id_2` is still unclaimed, so this bypasses the wallet CLI the same way. + token_send_claiming_new_account(&mut ctx, supply_account_id_2, recipient_account_id_2, 7) + .await?; info!("=================== SETUP FINISHED ==============="); @@ -351,13 +344,9 @@ async fn amm_new_pool_using_labels() -> Result<()> { ) .await?; - token_send( - &mut ctx, - public_mention(supply_account_id_1), - public_mention(holding_a_id), - 5, - ) - .await?; + // `holding_a_id` is still unclaimed, so bypass the wallet CLI (see + // `token_send_claiming_new_account`'s docs for why). + token_send_claiming_new_account(&mut ctx, supply_account_id_1, holding_a_id, 5).await?; // Create token 2 and distribute to holding_b create_token( @@ -369,13 +358,8 @@ async fn amm_new_pool_using_labels() -> Result<()> { ) .await?; - token_send( - &mut ctx, - public_mention(supply_account_id_2), - public_mention(holding_b_id), - 5, - ) - .await?; + // `holding_b_id` is still unclaimed, so bypass the wallet CLI the same way. + token_send_claiming_new_account(&mut ctx, supply_account_id_2, holding_b_id, 5).await?; // Create AMM pool using account labels instead of IDs let subcommand = AmmProgramAgnosticSubcommand::New { diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index bc5c650b..83a77c4c 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -128,6 +128,47 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { Ok(()) } +/// A deshielded transfer's public recipient must not be asked to sign the transaction: the +/// sender's private-side proof is the only authorization the protocol requires, and signing +/// with the recipient's key (when the wallet happens to hold it) would leak a link between +/// the two accounts. +#[test] +async fn deshielded_transfer_does_not_sign_with_recipient_key() -> Result<()> { + let mut ctx = TestContext::new().await?; + + let from: AccountId = ctx.existing_private_accounts()[0]; + let to: AccountId = ctx.existing_public_accounts()[1]; + + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { + from: private_mention(from), + to: Some(public_mention(to)), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount: 100, + }); + + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else { + anyhow::bail!("Expected TransactionExecuted return value"); + }; + + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; + + assert!( + tx.witness_set().signatures_and_public_keys().is_empty(), + "deshielded transfer must not carry any signature, in particular not the recipient's" + ); + + info!("Deshielded transfer correctly did not sign with the recipient's key"); + + Ok(()) +} + #[test] async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { let mut ctx = TestContext::new().await?; diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index c42bc20c..ea0838ef 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -1,19 +1,19 @@ use std::time::Duration; -use anyhow::Result; +use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, new_account, - public_mention, send, + public_mention, send, send_claiming_new_account, }; -use lee::public_transaction; +use lee::{PublicKey, public_transaction}; use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ account::Label, cli::{ - CliAccountMention, Command, account::AccountSubcommand, + CliAccountMention, Command, SubcommandReturnValue, account::AccountSubcommand, programs::native_token_transfer::AuthTransferSubcommand, }, }; @@ -24,13 +24,20 @@ async fn successful_transfer_to_existing_account() -> Result<()> { let sender = ctx.existing_public_accounts()[0]; let receiver = ctx.existing_public_accounts()[1]; - send( - &mut ctx, - public_mention(sender), - public_mention(receiver), - 100, - ) - .await?; + + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { + from: public_mention(sender), + to: Some(public_mention(receiver)), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount: 100, + }); + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else { + anyhow::bail!("Expected TransactionExecuted return value"); + }; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; @@ -45,6 +52,34 @@ async fn successful_transfer_to_existing_account() -> Result<()> { assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); + // The recipient already exists, so the protocol doesn't require its signature, and the + // wallet must never sign with a key it doesn't need to use. Assert the transfer's witness + // set contains exactly the sender's signature, not the recipient's. + let (tx, _block_id) = ctx + .sequencer_client() + .get_transaction(tx_hash) + .await? + .context("transfer transaction should be included in a block")?; + let LeeTransaction::Public(tx) = tx else { + anyhow::bail!("Expected a public transaction"); + }; + let sender_public_key = PublicKey::new_from_private_key( + ctx.wallet() + .get_account_public_signing_key(sender) + .context("sender should have a signing key")?, + ); + let signers: Vec<_> = tx + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, public_key)| public_key) + .collect(); + assert_eq!( + signers, + vec![&sender_public_key], + "only the sender should sign a transfer to an existing account" + ); + Ok(()) } @@ -55,16 +90,9 @@ pub async fn successful_transfer_to_new_account() -> Result<()> { let new_persistent_account_id = new_account(&mut ctx, false, None).await?; let sender = ctx.existing_public_accounts()[0]; - send( - &mut ctx, - public_mention(sender), - public_mention(new_persistent_account_id), - 100, - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + // The wallet CLI never signs with the recipient's key, but claiming this fresh account + // requires it, so bypass the CLI for this one send. + send_claiming_new_account(&mut ctx, sender, new_persistent_account_id, 100).await?; info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 9107314a..919b3dbc 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -8,6 +8,13 @@ //! zone B, where the indexer re-derives and verifies it (Option B) before the //! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged; //! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new. +//! +//! Not production-safe. The inbox allowlist gates the target program, not the +//! source emitter, and `extract_emission` recognizes any known emitter, so in a +//! zone that allows `wrapped_token` as a target a permissionless `ping_sender` +//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe +//! needs source verification, where a value-bearing target checks the message +//! originated from `bridge_lock`; that is out of scope for the demo. use std::time::Duration; @@ -103,16 +110,12 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { // escrow now. let seq_a_client = sequencer_client(seq_a.addr())?; let escrow_id = bridge_lock_core::escrow_account_id(programs::bridge_lock().id()); - let escrowed = bridge_lock_core::read_balance( - &seq_a_client.get_account(escrow_id).await?.data.into_inner(), - ); + let escrowed = seq_a_client.get_account(escrow_id).await?.balance; assert_eq!( escrowed, LOCK_AMOUNT, "zone A escrow must hold the locked amount" ); - let remaining = bridge_lock_core::read_balance( - &seq_a_client.get_account(holder_id).await?.data.into_inner(), - ); + let remaining = seq_a_client.get_account(holder_id).await?.balance; assert_eq!( remaining, INITIAL_BALANCE - LOCK_AMOUNT, diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 1b67914a..f1080c8c 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -54,7 +54,7 @@ fn seed_inbox_config( allowed_peers: BTreeMap::new(), allowed_targets, }; - state.insert_genesis_account( + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( inbox_config_account_id(inbox_id), Account { program_owner: inbox_id, @@ -65,14 +65,14 @@ fn seed_inbox_config( .expect("config fits in account data"), nonce: 0_u128.into(), }, - ); + )]); } /// Seeds the wrapped-token config account pinning the inbox as authorized minter, /// matching what genesis seeds for a real zone. fn seed_wrapped_config(state: &mut V03State) { let wrapped_token_id = programs::wrapped_token().id(); - state.insert_genesis_account( + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( wrapped_token_core::config_account_id(wrapped_token_id), Account { program_owner: wrapped_token_id, @@ -82,7 +82,7 @@ fn seed_wrapped_config(state: &mut V03State) { .expect("minter id fits in account data"), ..Default::default() }, - ); + )]); } /// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone @@ -170,18 +170,14 @@ fn lock_escrows_balance_and_emits_to_outbox() { let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); - state.insert_genesis_account( + state = state.with_public_accounts([( holder_id, Account { program_owner: bridge_lock_id, - balance: 0, - data: bridge_lock_core::balance_bytes(INITIAL_BALANCE) - .to_vec() - .try_into() - .expect("balance fits in account data"), - nonce: 0_u128.into(), + balance: INITIAL_BALANCE, + ..Default::default() }, - ); + )]); let payload = mint_payload(); let target_accounts = vec![ @@ -214,16 +210,14 @@ fn lock_escrows_balance_and_emits_to_outbox() { .expect("lock must validate and execute"); let public_diff = diff.public_diff(); - let holder_after = - bridge_lock_core::read_balance(&public_diff[&holder_id].data.clone().into_inner()); + let holder_after = public_diff[&holder_id].balance; assert_eq!( holder_after, INITIAL_BALANCE - LOCK_AMOUNT, "holder debited" ); - let escrow_after = - bridge_lock_core::read_balance(&public_diff[&escrow_id].data.clone().into_inner()); + let escrow_after = public_diff[&escrow_id].balance; assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited"); let record = @@ -314,7 +308,7 @@ fn mint_replay_rejected() { let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let mut shard = SeenShard::default(); shard.insert(message_key(&src_zone, src_block_id, src_tx_index)); - state.insert_genesis_account( + state = state.with_public_accounts([( seen_id, Account { program_owner: inbox_id, @@ -325,7 +319,7 @@ fn mint_replay_rejected() { .expect("shard fits in account data"), nonce: 0_u128.into(), }, - ); + )]); let msg = CrossZoneMessage { src_zone, diff --git a/integration_tests/tests/keys.rs b/integration_tests/tests/keys.rs index 10b5710e..4dade59a 100644 --- a/integration_tests/tests/keys.rs +++ b/integration_tests/tests/keys.rs @@ -10,7 +10,7 @@ use anyhow::{Context as _, Result}; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, assert_public_account_restored, fetch_privacy_preserving_tx, new_account, private_mention, public_mention, - restored_private_account, send, verify_commitment_is_in_state, + restored_private_account, send, send_claiming_new_account, verify_commitment_is_in_state, }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use lee::AccountId; @@ -121,21 +121,10 @@ async fn restore_keys_from_seed() -> Result<()> { let to_account_id3 = new_account(&mut ctx, false, Some(ChainIndex::root())).await?; let to_account_id4 = new_account(&mut ctx, false, Some(ChainIndex::from_str("/0")?)).await?; - // Send to both public accounts - send( - &mut ctx, - public_mention(from), - public_mention(to_account_id3), - 102, - ) - .await?; - send( - &mut ctx, - public_mention(from), - public_mention(to_account_id4), - 103, - ) - .await?; + // Send to both public accounts. Both are still unclaimed, so bypass the wallet CLI (which + // never signs with the recipient's key) and sign with the recipient's own key directly. + send_claiming_new_account(&mut ctx, from, to_account_id3, 102).await?; + send_claiming_new_account(&mut ctx, from, to_account_id4, 103).await?; info!("Preparation complete, performing keys restoration"); diff --git a/integration_tests/tests/sequencer_bootstrap.rs b/integration_tests/tests/sequencer_bootstrap.rs index 9dd2b586..c4468c1b 100644 --- a/integration_tests/tests/sequencer_bootstrap.rs +++ b/integration_tests/tests/sequencer_bootstrap.rs @@ -12,7 +12,6 @@ use std::{path::Path, time::Duration}; use anyhow::{Context as _, Result, bail}; use indexer_service_rpc::RpcClient as _; -use integration_tests::L2_TO_L1_TIMEOUT; use lee::{AccountId, PrivateKey, PublicKey}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use sequencer_core::config::GenesisAction; @@ -24,6 +23,10 @@ use test_fixtures::{ }; use tokio::test; +/// Finalization can lag several minutes under CI load; give the bootstrap and +/// reconstruction waits generous headroom so runner-speed variance does not flake. +const FINALIZE_TIMEOUT: Duration = Duration::from_mins(12); + /// Block cadence for the tests: short so we don't wait long for local production. fn fast_blocks() -> SequencerPartialConfig { SequencerPartialConfig { @@ -227,7 +230,7 @@ async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> { // Wait until those blocks are finalized on Bedrock — reconstruction only // reads finalized history. A stays alive so its publish task keeps flushing. - let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, L2_TO_L1_TIMEOUT).await?; + let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, FINALIZE_TIMEOUT).await?; // Stop A, then wipe just its L2 store (keeping the bedrock signing key) so it // restarts from an empty store on the same channel/identity — a sequencer that @@ -398,7 +401,7 @@ async fn local_ahead_of_channel_resumes() -> Result<()> { .await .context("Failed to start sequencer A")?; let client_a = sequencer_client(handle_a.addr())?; - let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?; + let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZE_TIMEOUT).await?; let tip_before = client_a.get_last_block_id().await?; assert!( tip_before > finalized, @@ -501,7 +504,7 @@ async fn local_behind_channel_reconstructs_forward() -> Result<()> { .setup_at(home.path()) .await .context("Failed to resume sequencer")?; - let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?; + let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZE_TIMEOUT).await?; drop(handle); tokio::time::sleep(Duration::from_secs(2)).await; finalized diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index d7578a22..215a1065 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -9,7 +9,7 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account, new_account, private_mention, - public_mention, sync_private, verify_commitment_is_in_state, + public_mention, sync_private, token_send_claiming_new_account, verify_commitment_is_in_state, }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use log::info; @@ -79,22 +79,17 @@ async fn create_and_transfer_public_token() -> Result<()> { } ); - // Transfer 7 tokens from supply_acc to recipient_account_id + // Transfer 7 tokens from supply_acc to recipient_account_id. `recipient_account_id` is + // still unclaimed, so this bypasses the wallet CLI (which never signs with the recipient's + // key) and signs with the recipient's own key directly. let transfer_amount = 7; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id), - to: Some(public_mention(recipient_account_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: transfer_amount, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + token_send_claiming_new_account( + &mut ctx, + supply_account_id, + recipient_account_id, + transfer_amount, + ) + .await?; // Check the status of the supply account after transfer let supply_acc = get_account(&ctx, supply_account_id).await?; @@ -962,21 +957,28 @@ async fn transfer_token_using_from_label() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Transfer token using from_label instead of from - let transfer_amount = 20; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: supply_label.into(), - to: Some(public_mention(recipient_account_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: transfer_amount, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; + // Confirm the label resolves to the account created for it. + let resolved_sender = ctx + .wallet() + .storage() + .resolve_label(&supply_label) + .context("supply_label should resolve to an account")?; + assert_eq!( + resolved_sender, + wallet::account::AccountIdWithPrivacy::Public(supply_account_id) + ); - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + // Transfer token from the label-resolved account. `recipient_account_id` is still + // unclaimed, so this bypasses the wallet CLI (which never signs with the recipient's key) + // and signs with the recipient's own key directly. + let transfer_amount = 20; + token_send_claiming_new_account( + &mut ctx, + supply_account_id, + recipient_account_id, + transfer_amount, + ) + .await?; let recipient_acc = get_account(&ctx, recipient_account_id).await?; let token_holding = TokenHolding::try_from(&recipient_acc.data)?; diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index bd1f940c..86f357e1 100644 --- a/lee/state_machine/src/lib.rs +++ b/lee/state_machine/src/lib.rs @@ -5,7 +5,7 @@ pub use lee_core::{ GENESIS_BLOCK_ID, SharedSecretKey, - account::{Account, AccountId, Data}, + account::{Account, AccountId, Balance, Data}, encryption::EphemeralPublicKey, program::ProgramId, }; diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index 6a59cadb..8d278ea5 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -199,14 +199,6 @@ impl V03State { self.programs.insert(program.id(), program); } - /// Seeds a single genesis account that is not produced by any transaction - /// (e.g. the cross-zone inbox config or a bridge-lock holding). Lets the - /// sequencer and indexer seed identical zone-specific state after building - /// the shared initial state. - pub fn insert_genesis_account(&mut self, account_id: AccountId, account: Account) { - self.public_state.insert(account_id, account); - } - pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) { let StateDiff { signer_account_ids, @@ -292,6 +284,58 @@ impl V03State { self.private_state.0.digest() } + /// Order-independent fingerprint of the genesis-relevant state: the public + /// account set, the deployed program set, and the commitment-set digest. + /// + /// The sequencer and the indexer build the directly-seeded part of genesis + /// (base builtins plus any directly-seeded accounts) separately from their own + /// configs, so a divergence there would otherwise go unnoticed. Both nodes log + /// this at startup; equal values mean the two genesis states agree. Entries are + /// sorted by id before hashing, so the value does not depend on `HashMap` + /// iteration order. + #[must_use] + pub fn genesis_fingerprint(&self) -> [u8; 32] { + use sha2::{Digest as _, Sha256}; + + // Destructure so adding a `V03State` field forces a decision here about + // whether it belongs in the genesis fingerprint. + let Self { + public_state, + private_state, + programs, + } = self; + + let mut accounts: Vec<(&AccountId, &Account)> = public_state.iter().collect(); + accounts.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref())); + + let mut program_ids: Vec = programs.keys().copied().collect(); + program_ids.sort_unstable(); + + let account_count = u64::try_from(accounts.len()).expect("account count fits in u64"); + let program_count = u64::try_from(program_ids.len()).expect("program count fits in u64"); + + let mut hasher = Sha256::new(); + hasher.update(account_count.to_le_bytes()); + for (id, account) in accounts { + hasher.update(id.as_ref()); + let bytes = borsh::to_vec(account).expect("Account is BorshSerialize"); + let len = u64::try_from(bytes.len()).expect("account encoding fits in u64"); + hasher.update(len.to_le_bytes()); + hasher.update(&bytes); + } + hasher.update(program_count.to_le_bytes()); + for id in program_ids { + for word in id { + hasher.update(word.to_le_bytes()); + } + } + hasher.update(private_state.0.digest()); + + let mut out = [0_u8; 32]; + out.copy_from_slice(&hasher.finalize()); + out + } + pub(crate) fn check_commitments_are_new( &self, new_commitments: &[Commitment], diff --git a/lez/cross_zone/Cargo.toml b/lez/cross_zone/Cargo.toml index 465436f1..25341bda 100644 --- a/lez/cross_zone/Cargo.toml +++ b/lez/cross_zone/Cargo.toml @@ -14,4 +14,6 @@ programs.workspace = true cross_zone_inbox_core.workspace = true bridge_lock_core.workspace = true ping_core.workspace = true +wrapped_token_core.workspace = true +serde.workspace = true risc0-zkvm.workspace = true diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 544d4286..06ebd8a0 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -2,6 +2,12 @@ //! machine (`lee`), kept out of the guest-pure cores. Mirrors `system_accounts`: //! it resolves builtin program ids and bakes them into transactions and genesis //! accounts for the watcher (sequencer) and verifier (indexer). +//! +//! This crate is the reference LEZ-to-LEZ adapter: it re-derives each delivery +//! byte-for-byte from a peer LEZ zone's finalized blocks, valid only because the +//! peer runs identical LEZ code. A non-LEZ peer needs a separate adapter with its +//! own block-reading, emission-extraction, delivery-building, and trust model; a +//! shared trait is best lifted from that first real adapter, not from this one. use std::collections::BTreeMap; @@ -11,9 +17,10 @@ use cross_zone_inbox_core::{ inbox_seen_shard_account_id, }; use lee_core::{ - account::{Account, AccountId}, + account::{Account, AccountId, Balance}, program::ProgramId, }; +use serde::Serialize; /// The cross-zone emission fields a watcher or verifier reads off a source /// transaction, common to every emitter program. @@ -136,54 +143,78 @@ pub fn build_dispatch_from_emission( build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids) } -/// Builds the inbox config account a zone seeds into genesis state. -/// -/// Lets the inbox guest authorize inbound peer messages. The sequencer and -/// indexer seed the same account from the same config, keeping their replayed -/// state consistent. -#[must_use] -pub fn build_inbox_config_account( - self_zone: ZoneId, - cross_zone: &CrossZoneConfig, -) -> (AccountId, Account) { - let inbox_id = programs::cross_zone_inbox().id(); - +/// The inbox config a zone derives from its cross-zone config: the per-peer target +/// allowlists plus its own zone id. +fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig { let mut allowed_targets = BTreeMap::new(); for peer in &cross_zone.peers { allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone()); } - let config = InboxConfig { + InboxConfig { self_zone, allowed_peers: BTreeMap::new(), allowed_targets, - }; + } +} - let account = Account { - program_owner: inbox_id, - balance: 0, - data: config - .to_bytes() - .try_into() - .expect("inbox config fits in account data"), - nonce: 0_u128.into(), - }; - (inbox_config_account_id(inbox_id), account) +/// The genesis transaction that initializes this zone's inbox config PDA. +/// +/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the +/// same account on every node, keeping their state consistent. +#[must_use] +pub fn build_inbox_init_config_tx( + self_zone: ZoneId, + cross_zone: &CrossZoneConfig, +) -> lee::PublicTransaction { + let inbox_id = programs::cross_zone_inbox().id(); + genesis_public_tx( + inbox_id, + vec![inbox_config_account_id(inbox_id)], + Instruction::InitConfig(inbox_config(self_zone, cross_zone)), + ) } /// Builds the genesis holding account funding a holder's bridgeable balance. /// -/// Owned by `bridge_lock`, data is the LE balance. Not produced by any -/// transaction, so the sequencer and indexer both seed it through this one -/// builder. +/// A real native balance owned by `bridge_lock`, which can debit it on a lock; it +/// is conserved like any other balance. Not produced by any transaction, so the +/// sequencer and indexer both seed it through this one builder. #[must_use] -pub fn build_holding_account(holder: AccountId, amount: u128) -> (AccountId, Account) { +pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, Account) { let account = Account { program_owner: programs::bridge_lock().id(), - data: bridge_lock_core::balance_bytes(amount) - .to_vec() - .try_into() - .expect("balance fits in account data"), + balance: amount, ..Default::default() }; (holder, account) } + +/// The genesis transaction that pins the cross-zone inbox as the wrapped-token +/// minter, without importing the inbox id into the guest. +#[must_use] +pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction { + let wrapped_token_id = programs::wrapped_token().id(); + genesis_public_tx( + wrapped_token_id, + vec![wrapped_token_core::config_account_id(wrapped_token_id)], + wrapped_token_core::Instruction::InitConfig { + minter: programs::cross_zone_inbox().id(), + }, + ) +} + +/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction` +/// on `program_id` over `account_ids`. +fn genesis_public_tx( + program_id: ProgramId, + account_ids: Vec, + instruction: I, +) -> lee::PublicTransaction { + let message = + lee::public_transaction::Message::try_new(program_id, account_ids, vec![], instruction) + .expect("genesis instruction must serialize"); + lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) +} diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index a5d66a23..ce6cfc61 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -27,17 +27,16 @@ impl IndexerStore { /// Creates files if necessary. pub fn open_db(location: &Path, genesis_seed: Vec<(AccountId, Account)>) -> Result { #[cfg(not(feature = "testnet"))] - let mut initial_state = testnet_initial_state::initial_state(); + let base = testnet_initial_state::initial_state(); #[cfg(feature = "testnet")] - let mut initial_state = testnet_initial_state::initial_state_testnet(); + let base = testnet_initial_state::initial_state_testnet(); - // Seed any zone-specific genesis accounts (the cross-zone inbox config and - // bridge-lock holdings) so the indexer's replayed state matches the - // sequencer's; none are produced by a transaction. - for (account_id, account) in genesis_seed { - initial_state.insert_genesis_account(account_id, account); - } + // Seed any zone-specific genesis accounts (the bridge-lock holdings) so the + // indexer's replayed state matches the sequencer's; none are produced by a + // transaction. Cross-zone programs are base builtins, and their config + // accounts are reconstructed by replaying the genesis block's InitConfig txs. + let initial_state = base.with_public_accounts(genesis_seed); let dbio = RocksDBIO::open_or_create(location, &initial_state)?; let current_state = dbio.final_state()?; diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 6e2a818e..5d95df4e 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -12,7 +12,7 @@ use cross_zone_inbox_core::{ }; use futures::StreamExt as _; use lee::PublicKey; -use log::{error, info}; +use log::{debug, error, info}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_zone_sdk::{ CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, @@ -51,8 +51,7 @@ impl PeerBlocks { } /// The highest block id this reader has finalized for `zone`, or `None` if it - /// has read nothing yet. Used to tell forgery (we have read past the - /// referenced block and it is absent) from lag (we simply have not caught up). + /// has read nothing yet. async fn highest_seen(&self, zone: ZoneId) -> Option { self.chains .read() @@ -65,13 +64,17 @@ impl PeerBlocks { /// The indexer-side Option B verifier. /// /// For every cross-zone dispatch in a block it re-derives the transaction from -/// the peer's finalized block and rejects it if the bytes differ (a forgery) or -/// the message was already delivered (a replay), so delivery no longer relies on -/// trusting the sequencer. +/// the peer's finalized block and rejects it if the bytes differ (a forgery), so +/// delivery no longer relies on trusting the sequencer. A replay of an +/// already-delivered message is accepted, since the inbox no-ops it on chain. #[derive(Clone)] pub struct CrossZoneVerifier { self_zone: ZoneId, /// Pinned block-signing key per peer zone, enforced during re-derivation. + /// One key per peer is sufficient while a zone has a single sequencer; key + /// sets with rotation come in with decentralized sequencing. The pin is + /// largely redundant given Bedrock's turn-based write authorization, so it is + /// optional: a peer with no configured key is not signature-checked. peer_pubkeys: HashMap, peers: PeerBlocks, seen: Arc>>, @@ -112,9 +115,17 @@ impl CrossZoneVerifier { }) } - /// Verifies every cross-zone dispatch in a block, returning `Err` on the - /// first forged or replayed dispatch. The caller halts ingestion on error. - pub async fn verify_block(&self, block: &Block) -> Result<()> { + /// Verifies every cross-zone dispatch in a block, returning `Err` on the first + /// forged dispatch (the caller halts ingestion) or the keys to mark seen. + /// + /// The caller MUST record the returned keys via [`Self::record_seen`] only + /// after the block applies, so the seen-set mirrors the inbox's on-chain + /// seen-shard. Marking a key from a block that never applies would let a later + /// forged dispatch reuse it to skip re-derivation while the inbox delivers the + /// forgery. A key already seen is a replay the inbox no-ops, so it is accepted + /// without re-derivation rather than halting on a legitimate re-delivery. + pub async fn verify_block(&self, block: &Block) -> Result> { + let mut verified = Vec::new(); for tx in &block.body.transactions { let Some(msg) = Self::decode_dispatch(tx) else { continue; @@ -122,10 +133,13 @@ impl CrossZoneVerifier { let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); if self.seen.read().await.contains(&key) { - bail!( - "cross-zone replay: message {} re-delivered", - hex::encode(key) + debug!( + "Skipping already-seen cross-zone dispatch from zone {} block {} tx {} (replay no-op)", + hex::encode(msg.src_zone), + msg.src_block_id, + msg.src_tx_index ); + continue; } let expected = self.rederive(&msg).await?; @@ -138,15 +152,25 @@ impl CrossZoneVerifier { ); } - self.seen.write().await.insert(key); info!( "Verified cross-zone dispatch from zone {} block {} tx {}", hex::encode(msg.src_zone), msg.src_block_id, msg.src_tx_index ); + verified.push(key); } - Ok(()) + Ok(verified) + } + + /// Marks the given dispatch keys seen, so a later replay of them is accepted + /// without re-derivation. Call only after the block that carried them has been + /// applied on chain (see [`Self::verify_block`]). + pub async fn record_seen(&self, keys: Vec) { + if keys.is_empty() { + return; + } + self.seen.write().await.extend(keys); } /// Decodes a transaction into the cross-zone message it dispatches, or `None` @@ -162,7 +186,9 @@ impl CrossZoneVerifier { &public_tx.message().instruction_data, ) { Ok(InboxInstruction::Dispatch(msg)) => Some(msg), - Err(_) => None, + // Only a dispatch carries a cross-zone message to re-derive; a genesis + // `InitConfig` is not verifier-relevant. + Ok(InboxInstruction::InitConfig(_)) | Err(_) => None, } } @@ -225,11 +251,6 @@ impl CrossZoneVerifier { /// If the block is cached, return it. If our peer reader has already /// finalized past `block_id` and we still do not have it, the reference is to /// a block that does not exist on the peer chain, a forgery, so reject now. - /// Otherwise the reader simply has not caught up yet: keep waiting, since a - /// legitimate dispatch is only injected after its peer block finalized and - /// our reader of the same finalized chain will see it too. Rejecting on a - /// timeout here would turn a lagging reader into a permanent halt of an - /// honest message. async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result { let mut waited = Duration::ZERO; loop { @@ -474,7 +495,7 @@ mod tests { } #[tokio::test] - async fn rejects_replayed_dispatch() { + async fn accepts_replayed_dispatch_as_noop() { let verifier = verifier(); verifier .peers @@ -485,15 +506,62 @@ mod tests { .await; let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]); - verifier + let keys = verifier .verify_block(&first) .await .expect("first delivery verifies"); + // Mark the delivery seen, as the ingest loop does once the block applies. + verifier.record_seen(keys).await; + + // Replace the peer block with a different emission so re-deriving the + // replay would mismatch. The replay must still be accepted, proving it is + // the seen-key short-circuit (the inbox no-ops it on chain) and not a + // successful re-derivation. + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"different")]), + ) + .await; let replay = produce_dummy_block(10, None, vec![dispatch(b"hi")]); - let err = verifier.verify_block(&replay).await.unwrap_err(); + verifier + .verify_block(&replay) + .await + .expect("a replay is accepted as an on-chain no-op"); + } + + #[tokio::test] + async fn unaccepted_dispatch_does_not_poison_seen() { + // A dispatch verified in a block that never applies (e.g. one that parks) + // must not be marked seen. Otherwise a later forged dispatch could reuse + // its key to skip re-derivation, while the inbox, never having recorded + // the key on chain, would deliver the forgery. + let verifier = verifier(); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + // The dispatch verifies, but the block is not applied, so record_seen is + // not called (the ingest loop records only after an Applied outcome). + let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&first) + .await + .expect("dispatch verifies"); + + // A forged dispatch reusing the same key (same src zone, block, tx index) + // with a different payload must still be re-derived and rejected, since + // its key was never recorded as seen. + let forged = produce_dummy_block(10, None, vec![dispatch(b"forged")]); + let err = verifier.verify_block(&forged).await.unwrap_err(); assert!( - err.to_string().contains("replay"), + err.to_string().contains("forged"), "unexpected error: {err}" ); } diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index 5a4909f4..4cf7d58f 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -90,23 +90,15 @@ impl IndexerCore { ); let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone()); - // Genesis accounts the indexer must seed to match the sequencer's state, - // since none are produced by a transaction: the cross-zone inbox config - // and any bridge-lock holdings. Both go through the same builders the - // sequencer uses, so the states are byte-identical. - let mut genesis_seed = Vec::new(); - if let Some(cross_zone) = config.cross_zone.as_ref() { - let self_zone: [u8; 32] = *config.channel_id.as_ref(); - genesis_seed.push(cross_zone::build_inbox_config_account( - self_zone, cross_zone, - )); - } - for holding in &config.bridge_lock_holdings { - genesis_seed.push(cross_zone::build_holding_account( - holding.holder, - holding.amount, - )); - } + // Cross-zone programs are base builtins, and their config accounts are + // reconstructed by replaying the genesis block's InitConfig transactions; + // neither is seeded here. Only bridge-lock holdings (source side), not + // produced by any transaction, are still seeded directly. + let genesis_accounts: Vec<_> = config + .bridge_lock_holdings + .iter() + .map(|holding| cross_zone::build_holding_account(holding.holder, holding.amount)) + .collect(); // Option B verifier: re-derives each cross-zone dispatch from the peer's // finalized blocks. `None` when cross-zone messaging is disabled. @@ -114,7 +106,7 @@ impl IndexerCore { Ok(Self { zone_indexer: Arc::new(zone_indexer), - store: IndexerStore::open_db(&home, genesis_seed)?, + store: IndexerStore::open_db(&home, genesis_accounts)?, node, config, status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())), @@ -300,23 +292,33 @@ impl IndexerCore { }; // Option B: re-derive and verify every cross-zone dispatch - // before applying the block. A forged or replayed dispatch - // halts ingestion rather than persisting an invalid state. - if let Some(verifier) = &self.verifier - && let Err(err) = verifier.verify_block(&block).await - { - error!( - "Cross-zone verification failed for block {}: {err:#}. Halting indexer ingestion.", - block.header.block_id - ); - self.set_status(IndexerSyncStatus::error(format!( - "cross-zone verification failed: {err:#}" - ))); - return; - } + // before applying the block. A forged dispatch halts ingestion + // rather than persisting an invalid state; a replay is accepted + // since the inbox no-ops it on chain. The verified keys are + // marked seen only once the block applies (below), so a block + // that does not apply cannot poison the seen-set. + let verified_keys = match &self.verifier { + Some(verifier) => match verifier.verify_block(&block).await { + Ok(keys) => keys, + Err(err) => { + error!( + "Cross-zone verification failed for block {}: {err:#}. Halting indexer ingestion.", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::error(format!( + "cross-zone verification failed: {err:#}" + ))); + return; + } + }, + None => Vec::new(), + }; match self.store.accept_block(&block, slot).await { Ok(AcceptOutcome::Applied) => { + if let Some(verifier) = &self.verifier { + verifier.record_seen(verified_keys).await; + } retry_gate.reset(); info!("Indexed L2 block {}", block.header.block_id); self.set_status(IndexerSyncStatus::syncing()); diff --git a/lez/programs/bridge_lock/core/src/lib.rs b/lez/programs/bridge_lock/core/src/lib.rs index e5a36113..6d2aaf49 100644 --- a/lez/programs/bridge_lock/core/src/lib.rs +++ b/lez/programs/bridge_lock/core/src/lib.rs @@ -39,30 +39,10 @@ pub const fn escrow_seed() -> PdaSeed { PdaSeed::new(ESCROW_SEED_DOMAIN) } -/// Reads a bridgeable balance from account data; empty data is a zero balance. -#[must_use] -pub fn read_balance(data: &[u8]) -> u128 { - if data.len() < 16 { - return 0; - } - u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!())) -} - -#[must_use] -pub const fn balance_bytes(amount: u128) -> [u8; 16] { - amount.to_le_bytes() -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn balance_round_trips() { - assert_eq!(read_balance(&balance_bytes(7)), 7); - assert_eq!(read_balance(&[]), 0); - } - #[test] fn escrow_is_stable() { let id: ProgramId = [4; 8]; diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index aa8b5f93..8b176ee5 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -1,4 +1,4 @@ -use bridge_lock_core::{Instruction, balance_bytes, escrow_account_id, escrow_seed, read_balance}; +use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed}; use cross_zone_outbox_core::Instruction as OutboxInstruction; use lee_core::{ account::AccountWithMetadata, @@ -36,7 +36,10 @@ fn main() { let WrappedInstruction::Mint { amount: mint_amount, .. - } = decode_mint(&payload); + } = decode_mint(&payload) + else { + panic!("bridge_lock payload must be a wrapped-token mint"); + }; assert_eq!( mint_amount, amount, "locked amount must equal the wrapped mint amount" @@ -47,6 +50,10 @@ fn main() { .expect("Lock requires holder, escrow, and outbox accounts"); assert!(holder.is_authorized, "holder must authorize the lock"); + // The holder holding is bridge_lock-owned, so bridge_lock may debit its native + // balance directly (state-machine rule 5). This also pins the transfer to a + // genuine holding: a caller cannot substitute an account owned by some other + // program to emit the mint without an actual lock. assert_eq!( holder.account.program_owner, self_program_id, "holder account must be a bridge_lock holding" @@ -57,25 +64,26 @@ fn main() { "second account must be the escrow PDA" ); - let holder_new = read_balance(&holder.account.data.clone().into_inner()) + // Move the real native balance holder -> escrow. bridge_lock owns both accounts, + // so it debits the holder and credits the escrow directly; conservation holds + // because the same amount moves between them. + let holder_new = holder + .account + .balance .checked_sub(amount) .expect("insufficient balance to lock"); - let escrow_new = read_balance(&escrow.account.data.clone().into_inner()) + let escrow_new = escrow + .account + .balance .checked_add(amount) .expect("escrow balance overflow"); let mut holder_account = holder.account.clone(); - holder_account.data = balance_bytes(holder_new) - .to_vec() - .try_into() - .expect("balance fits in account data"); + holder_account.balance = holder_new; let holder_post = AccountPostState::new(holder_account); let mut escrow_account = escrow.account.clone(); - escrow_account.data = balance_bytes(escrow_new) - .to_vec() - .try_into() - .expect("balance fits in account data"); + escrow_account.balance = escrow_new; let escrow_post = AccountPostState::new_claimed_if_default(escrow_account, Claim::Pda(escrow_seed())); diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index 761a8a1c..4323db6b 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -116,6 +116,10 @@ impl SeenShard { pub enum Instruction { /// Delivers a finalized peer message to its target program. Dispatch(CrossZoneMessage), + /// Initializes the inbox config account at genesis. Written once, into a + /// default (unclaimed) config PDA; the guest refuses a non-default pre-state, + /// so it cannot be re-run to overwrite the allowlists. + InitConfig(InboxConfig), } /// Content-addressed replay key for a delivered message. @@ -142,7 +146,14 @@ pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> M /// The config account holding the allowlists. #[must_use] pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId { - AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED)) + AccountId::for_public_pda(&inbox_id, &inbox_config_seed()) +} + +/// Seed of the config PDA, exposed so the guest can claim the account when it +/// initializes the config at genesis. +#[must_use] +pub const fn inbox_config_seed() -> PdaSeed { + PdaSeed::new(INBOX_CONFIG_SEED) } /// The seen-set shard for the `(src_zone, epoch)` the message falls in. diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index 8bb06ed8..bea71c69 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -1,10 +1,13 @@ use cross_zone_inbox_core::{ - InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id, - inbox_seen_shard_seed, message_key, + CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id, + inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, message_key, }; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, }; fn unchanged(pre: &AccountWithMetadata) -> AccountPostState { @@ -27,8 +30,32 @@ fn main() { "Inbox is only invoked as a top-level sequencer-origin transaction" ); - let Instruction::Dispatch(msg) = instruction; + match instruction { + Instruction::Dispatch(msg) => dispatch( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &msg, + ), + Instruction::InitConfig(config) => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &config, + ), + } +} +/// Delivers a finalized peer message to its target program, no-op on replay. +fn dispatch( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + msg: &CrossZoneMessage, +) { assert!( msg.l1_inclusion_witness.is_none(), "l1_inclusion_witness must be None in v1" @@ -123,3 +150,56 @@ fn main() { .with_chained_calls(chained_calls) .write(); } + +/// Writes the inbox config (peer + target allowlists) into the config PDA exactly +/// once at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + config: &InboxConfig, +) { + // pre_states: [config PDA]. + let [config_meta] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config_meta.account_id, + inbox_config_account_id(self_program_id), + "account must be the inbox config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned config must already hold exactly these allowlists (the + // genesis block is replayed onto seeded state during multi-sequencer + // reconstruction), otherwise reject a post-genesis attempt to change them. + // `new_claimed_if_default` alone would not stop the owning program from + // rewriting its own config data on a later call. + if config_meta.account != Account::default() { + assert_eq!( + config_meta.account.program_owner, self_program_id, + "inbox config PDA is owned by another program" + ); + assert_eq!( + config_meta.account.data.clone().into_inner(), + config.to_bytes(), + "inbox config already initialized with different allowlists" + ); + } + + let mut config_account = config_meta.account.clone(); + config_account.data = config + .to_bytes() + .try_into() + .expect("inbox config fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(inbox_config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config_meta], + vec![config_post], + ) + .write(); +} diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index 8e13567a..a95a5ca0 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -19,6 +19,12 @@ pub enum Instruction { /// Required accounts (2): the wrapped-token config PDA, then the recipient's /// holding PDA. Mint { recipient: [u8; 32], amount: u128 }, + /// Pins `minter` (the cross-zone inbox) as the authorized minter, written once + /// into a default config PDA at genesis. The guest refuses a non-default + /// pre-state, so it cannot be re-run to hijack the minter. + /// + /// Required accounts (1): the wrapped-token config PDA. + InitConfig { minter: ProgramId }, } /// PDA holding the authorized minter program id (the cross-zone inbox), seeded at diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index d0e7595f..19095e39 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -1,10 +1,10 @@ use lee_core::{ - account::AccountWithMetadata, + account::{Account, AccountWithMetadata}, program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; use wrapped_token_core::{ - Instruction, balance_bytes, config_account_id, holding_account_id, holding_seed, read_balance, - read_minter, + Instruction, balance_bytes, config_account_id, config_seed, holding_account_id, holding_seed, + minter_bytes, read_balance, read_minter, }; fn main() { @@ -18,8 +18,33 @@ fn main() { instruction_words, ) = read_lee_inputs::(); - let Instruction::Mint { recipient, amount } = instruction; + match instruction { + Instruction::Mint { recipient, amount } => mint( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + recipient, + amount, + ), + Instruction::InitConfig { minter } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + minter, + ), + } +} +fn mint( + self_program_id: lee_core::program::ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + recipient: [u8; 32], + amount: u128, +) { // pre_states: [config PDA, recipient holding PDA]. let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states) .expect("Mint requires the config and recipient holding accounts"); @@ -68,3 +93,60 @@ fn main() { ) .write(); } + +/// Writes the authorized minter into the config PDA exactly once at genesis. +fn init_config( + self_program_id: lee_core::program::ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + minter: lee_core::program::ProgramId, +) { + assert!( + caller_program_id.is_none(), + "InitConfig is a top-level genesis transaction" + ); + + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "account must be the wrapped-token config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned config must already hold exactly this minter (the + // genesis block is replayed onto seeded state during multi-sequencer + // reconstruction), otherwise reject a post-genesis attempt to set a different + // minter. `new_claimed_if_default` alone would not stop the owning program from + // rewriting its own config data on a later call. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "wrapped-token config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + minter_bytes(minter).to_vec(), + "wrapped-token config already initialized with a different minter" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = minter_bytes(minter) + .to_vec() + .try_into() + .expect("minter id fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], + ) + .write(); +} diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index a11a9988..29c30a4f 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -10,7 +10,7 @@ use bytesize::ByteSize; use common::config::BasicAuth; pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; use humantime_serde; -use lee::AccountId; +use lee::{AccountId, Balance}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use serde::{Deserialize, Serialize}; use url::Url; @@ -21,15 +21,15 @@ use url::Url; pub enum GenesisAction { SupplyAccount { account_id: AccountId, - balance: u128, + balance: Balance, }, SupplyBridgeAccount { - balance: u128, + balance: Balance, }, /// Seeds a bridge-lock holder's initial bridgeable balance into genesis state. SupplyBridgeLockHolding { holder: AccountId, - amount: u128, + amount: Balance, }, } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index c33dcac4..f2507430 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -217,15 +217,16 @@ impl SequencerCore { // Before producing, verify our local state still belongs to the chain // the channel serves and replay any channel blocks we are missing // (e.g. from other sequencers). - let channel_was_empty = + let channel_absent = Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start) .await .expect("Failed to verify/reconstruct sequencer state from Bedrock"); - // Publish our blocks only when bootstrapping an empty channel. If the - // channel already has blocks (another sequencer bootstrapped it), we - // adopted them during reconstruction instead. - if is_fresh_start && channel_was_empty { + // Publish our blocks only when we are bootstrapping a channel that does + // not exist yet (no channel tip). If the channel already exists (another + // sequencer created it), we adopted its blocks during reconstruction + // instead; republishing then would fork the channel with our own copies. + if is_fresh_start && channel_absent { let mut pending_blocks = store .get_all_blocks() .filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending)) @@ -269,7 +270,8 @@ impl SequencerCore { /// `state`/`store`, recording each block's L1 inscription slot as the new /// anchor. Fails (never parks) on any divergence. /// - /// Returns whatever channel was empty or not. + /// Returns whether the channel does not exist yet (has no tip), i.e. whether + /// this sequencer is the one that must bootstrap-publish its own blocks. async fn verify_and_reconstruct( publisher: &BP, store: &SequencerStore, @@ -351,7 +353,6 @@ impl SequencerCore { .await .context("Failed to read channel history for reconstruction")?; let mut messages = std::pin::pin!(messages); - let mut channel_is_empty = true; while let Some((message, slot)) = messages.next().await { if let Some(check) = &mut consistency_check && let Some(ChainConsistency::Inconsistent(mismatch)) = @@ -369,7 +370,6 @@ impl SequencerCore { slot.into_inner() ) })?; - channel_is_empty = false; // Locked per message (not across the stream `await`): concurrent // follow events interleave safely — both paths apply idempotently // and persist under this same lock. @@ -377,7 +377,12 @@ impl SequencerCore { Self::apply_reconstructed_block(store, &mut chain, &block, slot)?; } - Ok(channel_is_empty) + // The channel exists once it has a tip; only when it has none is this + // sequencer the one bootstrapping it. This is deliberately not the + // reconstruction scan's view above, which reads only finalized history + // (up to LIB) and so reports "empty" while finality lags even though the + // channel already holds unfinalized blocks from another sequencer. + Ok(channel_tip_slot.is_none()) } /// Applies a single channel block during reconstruction: idempotent for @@ -1088,33 +1093,22 @@ fn replay_unfulfilled_deposit_events( }); } -/// The pre-genesis state: `testnet_initial_state` plus accounts seeded outside -/// any transaction (bridge-lock holdings, the cross-zone inbox config). +/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings, +/// the only accounts seeded outside any transaction. Cross-zone config is seeded +/// by genesis `InitConfig` transactions and reconstructed by replaying them. fn build_initial_state(config: &SequencerConfig) -> lee::V03State { #[cfg(not(feature = "testnet"))] - let mut state = testnet_initial_state::initial_state(); + let base = testnet_initial_state::initial_state(); #[cfg(feature = "testnet")] - let mut state = testnet_initial_state::initial_state_testnet(); + let base = testnet_initial_state::initial_state_testnet(); - // Seed bridge-lock holder balances directly: they are not produced by any tx. - for action in &config.genesis { - if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action { - let (holder_id, account) = cross_zone::build_holding_account(*holder, *amount); - state.insert_genesis_account(holder_id, account); - } - } - - // Seed this zone's cross-zone inbox config so the inbox guest can authorize - // inbound peer messages (zone-specific config, not produced by any tx). - if let Some(cross_zone) = &config.cross_zone { - let self_zone = *config.bedrock_config.channel_id.as_ref(); - let (config_id, config_account) = - cross_zone::build_inbox_config_account(self_zone, cross_zone); - state.insert_genesis_account(config_id, config_account); - } - - state + // Bridge-lock holder balances belong to the source side and are not produced by + // any transaction, so seed them directly. Cross-zone config is seeded by genesis + // InitConfig transactions in `build_genesis_state`, not here. + let holdings = bridge_lock_holdings(&config.genesis) + .map(|(holder, amount)| cross_zone::build_holding_account(holder, amount)); + base.with_public_accounts(holdings) } /// Builds the initial genesis state from [`build_initial_state`] plus configured @@ -1124,22 +1118,42 @@ fn build_initial_state(config: &SequencerConfig) -> lee::V03State { fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec) { let mut state = build_initial_state(config); - let genesis_txs = config - .genesis - .iter() - .filter_map(|genesis_tx| match genesis_tx { - GenesisAction::SupplyAccount { - account_id, - balance, - } => Some(build_supply_account_genesis_transaction( - account_id, *balance, - )), - GenesisAction::SupplyBridgeAccount { balance } => { - Some(build_supply_bridge_account_genesis_transaction(*balance)) - } - // Force-inserted in `build_initial_state`: bridge_lock has no mint transaction. - GenesisAction::SupplyBridgeLockHolding { .. } => None, - }) + // Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's. + info!( + "Genesis fingerprint: {}", + hex::encode(state.genesis_fingerprint()) + ); + + // Config txs seed the config accounts by transaction, so every node + // reconstructs them by replaying the genesis block. The wrapped-token minter is + // initialized on every zone (wrapped_token is a builtin), since its InitConfig + // is user-callable and a config PDA left default would be claimable by anyone as + // the first initializer (a minter hijack). The inbox allowlist is initialized + // only on receiving zones; the inbox is sequencer-only, so its default config + // PDA is not user-claimable, merely unused until the zone receives. + let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx()); + let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| { + let self_zone = *config.bedrock_config.channel_id.as_ref(); + cross_zone::build_inbox_init_config_tx(self_zone, cross_zone) + }); + let supply_txs = config.genesis.iter().filter_map(|action| match action { + GenesisAction::SupplyAccount { + account_id, + balance, + } => Some(build_supply_account_genesis_transaction( + account_id, *balance, + )), + GenesisAction::SupplyBridgeAccount { balance } => { + Some(build_supply_bridge_account_genesis_transaction(*balance)) + } + // Seeded directly in `build_initial_state` (holdings via `build_holding_account`), not a + // genesis tx. + GenesisAction::SupplyBridgeLockHolding { .. } => None, + }); + + let genesis_txs = wrapped_token_config_tx + .chain(inbox_config_tx) + .chain(supply_txs) .chain(std::iter::once(clock_invocation(0))) .inspect(|tx| { state @@ -1152,6 +1166,16 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec impl Iterator + '_ { + genesis.iter().filter_map(|action| match action { + GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)), + GenesisAction::SupplyAccount { .. } | GenesisAction::SupplyBridgeAccount { .. } => None, + }) +} + /// Whether a program may only be invoked by sequencer-origin transactions. /// /// The cross-zone inbox is injected solely by the watcher; a user-submitted call diff --git a/lez/testnet_initial_state/Cargo.toml b/lez/testnet_initial_state/Cargo.toml index c0899ea9..f06d5f51 100644 --- a/lez/testnet_initial_state/Cargo.toml +++ b/lez/testnet_initial_state/Cargo.toml @@ -10,7 +10,6 @@ lee.workspace = true lee_core.workspace = true system_accounts.workspace = true programs.workspace = true -wrapped_token_core.workspace = true serde.workspace = true diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index c92371cd..f77a083f 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -258,30 +258,9 @@ fn initial_public_accounts() -> HashMap { .into_iter() .map(|clock_id| (clock_id, system_accounts::clock_account())), ) - .chain(std::iter::once(wrapped_token_config_account())) .collect() } -/// The wrapped-token config account. -/// -/// Seeded so the `wrapped_token` guest can pin its authorized minter (the -/// cross-zone inbox) without importing the inbox id. Fixed for every zone, so it -/// lives in the shared initial state. -fn wrapped_token_config_account() -> (AccountId, Account) { - let wrapped_token_id = programs::wrapped_token().id(); - ( - wrapped_token_core::config_account_id(wrapped_token_id), - Account { - program_owner: wrapped_token_id, - data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id()) - .to_vec() - .try_into() - .expect("minter id fits in account data"), - ..Default::default() - }, - ) -} - fn initial_programs() -> Vec { vec![ programs::authenticated_transfer(), @@ -292,8 +271,12 @@ fn initial_programs() -> Vec { programs::vault(), programs::faucet(), programs::bridge(), - programs::cross_zone_outbox(), + // Cross-zone programs are builtins: their bytecode is baked into every node, + // so registering them in the base state (rather than shipping ELFs through + // the genesis block, which exceeds the inscription size limit) keeps the two + // nodes in lock-step with nothing to desync. programs::cross_zone_inbox(), + programs::cross_zone_outbox(), programs::ping_sender(), programs::ping_receiver(), programs::bridge_lock(), diff --git a/lez/wallet-ffi/src/transfer.rs b/lez/wallet-ffi/src/transfer.rs index 1fcd3133..6f174f98 100644 --- a/lez/wallet-ffi/src/transfer.rs +++ b/lez/wallet-ffi/src/transfer.rs @@ -191,7 +191,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded( let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.send_shielded_transfer_to_outer_account( - from_mention.into_public_identity(from_id), + from_mention.into_public_identity(from_id, true), to_npk, to_vpk, to_identifier, @@ -464,7 +464,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded_owned( let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.send_shielded_transfer( - from_mention.into_public_identity(from_id), + from_mention.into_public_identity(from_id, true), to_id, amount, )) { diff --git a/lez/wallet/src/cli/mod.rs b/lez/wallet/src/cli/mod.rs index 16b6073a..26653b61 100644 --- a/lez/wallet/src/cli/mod.rs +++ b/lez/wallet/src/cli/mod.rs @@ -19,12 +19,14 @@ use crate::{ config::ConfigSubcommand, group::GroupSubcommand, keycard::KeycardSubcommand, + network::NetworkAlias, programs::{ amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand, bridge::BridgeSubcommand, native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand, vault::VaultSubcommand, }, }, + config::SequencerConnectionData, storage::Storage, }; @@ -33,6 +35,7 @@ pub mod chain; pub mod config; pub mod group; pub mod keycard; +pub mod network; pub mod programs; pub(crate) trait WalletSubcommand { @@ -80,6 +83,11 @@ pub enum Command { /// Command to setup config, get and set config fields. #[command(subcommand)] Config(ConfigSubcommand), + /// Change the network the wallet points to. + ChangeNetwork { + /// `testnet`, `local`, or a custom sequencer URL. + network: NetworkAlias, + }, /// Restoring keys from given password at given `depth`. /// /// !!!WARNING!!! will rewrite current storage. @@ -159,14 +167,22 @@ impl CliAccountMention { } } + /// Convert to an [`crate::AccountIdentity`] for use in a public transaction. + /// + /// The `sign` flag indicates whether to sign or not with the account keys. #[must_use] - pub fn into_public_identity(self, account_id: lee::AccountId) -> crate::AccountIdentity { + pub fn into_public_identity( + self, + account_id: lee::AccountId, + sign: bool, + ) -> crate::AccountIdentity { match self { Self::KeyPath(key_path) => crate::AccountIdentity::PublicKeycard { account_id, key_path, }, - Self::Id(_) | Self::Label(_) => crate::AccountIdentity::Public(account_id), + Self::Id(_) | Self::Label(_) if sign => crate::AccountIdentity::Public(account_id), + Self::Id(_) | Self::Label(_) => crate::AccountIdentity::PublicNoSign(account_id), } } } @@ -267,6 +283,20 @@ pub async fn execute_subcommand( Command::Config(config_subcommand) => { config_subcommand.handle_subcommand(wallet_core).await? } + Command::ChangeNetwork { network } => { + let sequencer_addr: url::Url = network.try_into().context("Invalid sequencer URL")?; + + let mut config = wallet_core.config().clone(); + config.sequencers = vec![SequencerConnectionData { + sequencer_addr, + basic_auth: None, + }]; + + wallet_core.set_config(config); + wallet_core.store_config_changes().await?; + + SubcommandReturnValue::Empty + } Command::RestoreKeys { depth } => { let mnemonic = read_mnemonic_from_stdin()?; let password = read_password_from_stdin()?; diff --git a/lez/wallet/src/cli/network.rs b/lez/wallet/src/cli/network.rs new file mode 100644 index 00000000..887f4e36 --- /dev/null +++ b/lez/wallet/src/cli/network.rs @@ -0,0 +1,38 @@ +use std::{convert::Infallible, str::FromStr}; + +use url::Url; + +const TESTNET_SEQUENCER_ADDR: &str = "https://testnet.lez.logos.co"; +const LOCAL_SEQUENCER_ADDR: &str = "http://127.0.0.1:3040"; + +/// A `change-network` argument: `testnet`, `local`, or a custom sequencer URL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NetworkAlias { + Testnet, + Local, + Other(String), +} + +impl FromStr for NetworkAlias { + type Err = Infallible; + + fn from_str(network: &str) -> Result { + Ok(match network { + "testnet" => Self::Testnet, + "local" => Self::Local, + other => Self::Other(other.to_owned()), + }) + } +} + +impl TryFrom for Url { + type Error = url::ParseError; + + fn try_from(alias: NetworkAlias) -> Result { + match alias { + NetworkAlias::Testnet => TESTNET_SEQUENCER_ADDR.parse(), + NetworkAlias::Local => LOCAL_SEQUENCER_ADDR.parse(), + NetworkAlias::Other(url) => url.parse(), + } + } +} diff --git a/lez/wallet/src/cli/programs/amm.rs b/lez/wallet/src/cli/programs/amm.rs index 0f544c42..61f6dfe1 100644 --- a/lez/wallet/src/cli/programs/amm.rs +++ b/lez/wallet/src/cli/programs/amm.rs @@ -138,9 +138,9 @@ impl AmmProgramAgnosticSubcommand { ) => { let tx_hash = Amm(wallet_core) .send_new_definition( - user_holding_a.into_public_identity(a), - user_holding_b.into_public_identity(b), - user_holding_lp.into_public_identity(lp), + user_holding_a.into_public_identity(a, true), + user_holding_b.into_public_identity(b, true), + user_holding_lp.into_public_identity(lp, true), balance_a, balance_b, ) @@ -170,8 +170,8 @@ impl AmmProgramAgnosticSubcommand { (AccountIdWithPrivacy::Public(a), AccountIdWithPrivacy::Public(b)) => { let tx_hash = Amm(wallet_core) .send_swap_exact_input( - user_holding_a.into_public_identity(a), - user_holding_b.into_public_identity(b), + user_holding_a.into_public_identity(a, true), + user_holding_b.into_public_identity(b, true), amount_in, min_amount_out, token_definition, @@ -202,8 +202,8 @@ impl AmmProgramAgnosticSubcommand { (AccountIdWithPrivacy::Public(a), AccountIdWithPrivacy::Public(b)) => { let tx_hash = Amm(wallet_core) .send_swap_exact_output( - user_holding_a.into_public_identity(a), - user_holding_b.into_public_identity(b), + user_holding_a.into_public_identity(a, true), + user_holding_b.into_public_identity(b, true), exact_amount_out, max_amount_in, token_definition, @@ -240,9 +240,9 @@ impl AmmProgramAgnosticSubcommand { ) => { let tx_hash = Amm(wallet_core) .send_add_liquidity( - user_holding_a.into_public_identity(a), - user_holding_b.into_public_identity(b), - user_holding_lp.into_public_identity(lp), + user_holding_a.into_public_identity(a, true), + user_holding_b.into_public_identity(b, true), + user_holding_lp.into_public_identity(lp, true), min_amount_lp, max_amount_a, max_amount_b, @@ -281,7 +281,7 @@ impl AmmProgramAgnosticSubcommand { .send_remove_liquidity( a, b, - user_holding_lp.into_public_identity(lp), + user_holding_lp.into_public_identity(lp, true), balance_lp, min_amount_a, min_amount_b, diff --git a/lez/wallet/src/cli/programs/ata.rs b/lez/wallet/src/cli/programs/ata.rs index 1d207b35..0a9f3221 100644 --- a/lez/wallet/src/cli/programs/ata.rs +++ b/lez/wallet/src/cli/programs/ata.rs @@ -94,7 +94,7 @@ impl AtaSubcommand { match owner_resolved { AccountIdWithPrivacy::Public(owner_id) => { let tx_hash = Ata(wallet_core) - .send_create(owner.into_public_identity(owner_id), definition_id) + .send_create(owner.into_public_identity(owner_id, true), definition_id) .await?; wallet_core .poll_and_finalize_public_transaction(tx_hash) @@ -127,7 +127,7 @@ impl AtaSubcommand { AccountIdWithPrivacy::Public(from_id) => { let tx_hash = Ata(wallet_core) .send_transfer( - from.into_public_identity(from_id), + from.into_public_identity(from_id, true), definition_id, to_id, amount, @@ -162,7 +162,7 @@ impl AtaSubcommand { AccountIdWithPrivacy::Public(holder_id) => { let tx_hash = Ata(wallet_core) .send_burn( - holder.into_public_identity(holder_id), + holder.into_public_identity(holder_id, true), definition_id, amount, ) diff --git a/lez/wallet/src/cli/programs/native_token_transfer.rs b/lez/wallet/src/cli/programs/native_token_transfer.rs index aaa3efe1..8bf3ffe5 100644 --- a/lez/wallet/src/cli/programs/native_token_transfer.rs +++ b/lez/wallet/src/cli/programs/native_token_transfer.rs @@ -61,7 +61,7 @@ impl AuthTransferSubcommand { match resolved { AccountIdWithPrivacy::Public(pub_account_id) => { let tx_hash = NativeTokenTransfer(wallet_core) - .register_account(account_id.into_public_identity(pub_account_id)) + .register_account(account_id.into_public_identity(pub_account_id, true)) .await?; wallet_core @@ -123,8 +123,8 @@ impl AuthTransferSubcommand { (AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Public(to)) => { let to_mention = to_account.expect("matched Some branch"); NativeTokenTransferProgramSubcommand::Public { - from: Some(from_account.into_public_identity(from)), - to: Some(to_mention.into_public_identity(to)), + from: Some(from_account.into_public_identity(from, true)), + to: Some(to_mention.into_public_identity(to, false)), amount, } } @@ -143,7 +143,7 @@ impl AuthTransferSubcommand { (AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Private(to)) => { NativeTokenTransferProgramSubcommand::Shielded( NativeTokenTransferProgramSubcommandShielded::ShieldedOwned { - from: Some(from_account.into_public_identity(from)), + from: Some(from_account.into_public_identity(from, true)), to, amount, }, @@ -165,7 +165,7 @@ impl AuthTransferSubcommand { AccountIdWithPrivacy::Public(from) => { NativeTokenTransferProgramSubcommand::Shielded( NativeTokenTransferProgramSubcommandShielded::ShieldedForeign { - from: Some(from_account.into_public_identity(from)), + from: Some(from_account.into_public_identity(from, true)), to_npk, to_vpk, to_identifier, diff --git a/lez/wallet/src/cli/programs/token.rs b/lez/wallet/src/cli/programs/token.rs index 6486f58c..bd00edba 100644 --- a/lez/wallet/src/cli/programs/token.rs +++ b/lez/wallet/src/cli/programs/token.rs @@ -208,7 +208,7 @@ impl TokenProgramAgnosticSubcommand { (AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Private(to)) => { TokenProgramSubcommand::Shielded( TokenProgramSubcommandShielded::TransferTokenShieldedOwned { - sender: Some(from_mention.into_public_identity(from)), + sender: Some(from_mention.into_public_identity(from, true)), recipient_account_id: to, balance_to_move: amount, }, @@ -237,7 +237,7 @@ impl TokenProgramAgnosticSubcommand { ), AccountIdWithPrivacy::Public(from) => TokenProgramSubcommand::Shielded( TokenProgramSubcommandShielded::TransferTokenShieldedForeign { - sender: Some(from_mention.into_public_identity(from)), + sender: Some(from_mention.into_public_identity(from, true)), recipient_npk: to_npk, recipient_vpk: to_vpk, recipient_identifier: to_identifier, @@ -830,8 +830,8 @@ impl TokenProgramSubcommandPublic { }; let tx_hash = Token(wallet_core) .send_transfer_transaction( - sender_account_id.into_public_identity(sender_id), - recipient_account_id.into_public_identity(recipient_id), + sender_account_id.into_public_identity(sender_id, true), + recipient_account_id.into_public_identity(recipient_id, false), balance_to_move, ) .await?; @@ -855,7 +855,7 @@ impl TokenProgramSubcommandPublic { let tx_hash = Token(wallet_core) .send_burn_transaction( definition_account_id, - holder_account_id.into_public_identity(holder_id), + holder_account_id.into_public_identity(holder_id, true), amount, ) .await?; @@ -881,8 +881,8 @@ impl TokenProgramSubcommandPublic { }; let tx_hash = Token(wallet_core) .send_mint_transaction( - definition_account_id.into_public_identity(def_id), - holder_account_id.into_public_identity(holder_id), + definition_account_id.into_public_identity(def_id, true), + holder_account_id.into_public_identity(holder_id, false), amount, ) .await?; @@ -1546,8 +1546,8 @@ impl CreateNewTokenProgramSubcommand { }; let tx_hash = Token(wallet_core) .send_new_definition( - definition_account_id.into_public_identity(def_id), - supply_account_id.into_public_identity(sup_id), + definition_account_id.into_public_identity(def_id, true), + supply_account_id.into_public_identity(sup_id, true), name, total_supply, ) diff --git a/lez/wallet/src/config.rs b/lez/wallet/src/config.rs index f9c1b748..51ba3877 100644 --- a/lez/wallet/src/config.rs +++ b/lez/wallet/src/config.rs @@ -77,7 +77,7 @@ impl Default for WalletConfig { fn default() -> Self { Self { sequencers: vec![SequencerConnectionData { - sequencer_addr: "http://127.0.0.1:3040".parse().unwrap(), + sequencer_addr: "https://testnet.lez.logos.co".parse().unwrap(), basic_auth: None, }], seq_poll_timeout: Duration::from_secs(12), diff --git a/lez/wallet/src/program_facades/native_token_transfer/deshielded.rs b/lez/wallet/src/program_facades/native_token_transfer/deshielded.rs index f060e0fa..04ca723a 100644 --- a/lez/wallet/src/program_facades/native_token_transfer/deshielded.rs +++ b/lez/wallet/src/program_facades/native_token_transfer/deshielded.rs @@ -19,7 +19,7 @@ impl NativeTokenTransfer<'_> { self.0 .resolve_private_account(from) .ok_or(ExecutionFailureKind::KeyNotFoundError)?, - AccountIdentity::Public(to), + AccountIdentity::PublicNoSign(to), ], instruction_data, &program.into(), diff --git a/programs/bridge_lock/core/Cargo.toml b/programs/bridge_lock/core/Cargo.toml deleted file mode 100644 index 2c9e2f58..00000000 --- a/programs/bridge_lock/core/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "bridge_lock_core" -version = "0.1.0" -edition = "2024" -license = { workspace = true } - -[lints] -workspace = true - -[dependencies] -lee_core.workspace = true -serde = { workspace = true, features = ["alloc"] } -lee = { workspace = true, optional = true } - -[features] -# Host-only genesis helper; pulls `lee`, so the risc0 guest builds without it. -host = ["dep:lee"] diff --git a/programs/bridge_lock/core/src/lib.rs b/programs/bridge_lock/core/src/lib.rs deleted file mode 100644 index eae1d681..00000000 --- a/programs/bridge_lock/core/src/lib.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Core types for the bridge-lock program, the source side of the cross-zone -//! token bridge. A holder locks part of their balance into an escrow and emits a -//! cross-zone message minting the wrapped token on the target zone. - -use lee_core::{ - account::AccountId, - program::{PdaSeed, ProgramId}, -}; -use serde::{Deserialize, Serialize}; - -const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/"; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum Instruction { - /// Lock `amount` of the holder's balance and emit a cross-zone message - /// minting the wrapped token on `target_zone`. The emission fields mirror - /// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly. - /// - /// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA. - Lock { - amount: u128, - target_zone: [u8; 32], - target_program_id: ProgramId, - target_accounts: Vec<[u8; 32]>, - payload: Vec, - outbox_program_id: ProgramId, - ordinal: u32, - }, -} - -/// PDA accumulating all locked balance on this zone. -#[must_use] -pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId { - AccountId::for_public_pda(&bridge_lock_id, &escrow_seed()) -} - -#[must_use] -pub fn escrow_seed() -> PdaSeed { - PdaSeed::new(ESCROW_SEED_DOMAIN) -} - -/// Reads a bridgeable balance from account data; empty data is a zero balance. -#[must_use] -pub fn read_balance(data: &[u8]) -> u128 { - if data.len() < 16 { - return 0; - } - u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!())) -} - -#[must_use] -pub fn balance_bytes(amount: u128) -> [u8; 16] { - amount.to_le_bytes() -} - -/// Builds the genesis holding account funding a holder's bridgeable balance: -/// owned by bridge_lock, data is the LE balance, at the holder's account id. It -/// is not produced by any transaction, so the sequencer and the indexer both -/// seed it through this one builder to keep their genesis states identical. -#[cfg(feature = "host")] -#[must_use] -pub fn build_holding_account( - holder: AccountId, - amount: u128, -) -> (AccountId, lee_core::account::Account) { - let account = lee_core::account::Account { - program_owner: lee::program::Program::bridge_lock().id(), - data: balance_bytes(amount) - .to_vec() - .try_into() - .expect("balance fits in account data"), - ..Default::default() - }; - (holder, account) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn balance_round_trips() { - assert_eq!(read_balance(&balance_bytes(7)), 7); - assert_eq!(read_balance(&[]), 0); - } - - #[test] - fn escrow_is_stable() { - let id: ProgramId = [4; 8]; - assert_eq!(escrow_account_id(id), escrow_account_id(id)); - } -} diff --git a/programs/cross_zone_inbox/core/Cargo.toml b/programs/cross_zone_inbox/core/Cargo.toml deleted file mode 100644 index 374a287d..00000000 --- a/programs/cross_zone_inbox/core/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "cross_zone_inbox_core" -version = "0.1.0" -edition = "2024" -license = { workspace = true } - -[lints] -workspace = true - -[dependencies] -lee_core.workspace = true -serde = { workspace = true, features = ["alloc"] } -risc0-zkvm.workspace = true -borsh.workspace = true -lee = { workspace = true, optional = true } -ping_core = { workspace = true, optional = true } -bridge_lock_core = { workspace = true, optional = true } - -[features] -# Host-only transaction builder and emission extractor; pull `lee` and the -# emitter cores, so the risc0 guest builds without them. -host = ["dep:lee", "dep:ping_core", "dep:bridge_lock_core"] diff --git a/programs/cross_zone_inbox/core/src/lib.rs b/programs/cross_zone_inbox/core/src/lib.rs deleted file mode 100644 index edb90424..00000000 --- a/programs/cross_zone_inbox/core/src/lib.rs +++ /dev/null @@ -1,378 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use borsh::{BorshDeserialize, BorshSerialize}; -use lee_core::{ - account::AccountId, - program::{PdaSeed, ProgramId}, -}; -use serde::{Deserialize, Serialize}; - -/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. -pub type ZoneId = [u8; 32]; - -/// Block-signing public key pinned per peer zone. -pub type ExpectedPubkey = [u8; 32]; - -/// Content-addressed replay key for a delivered message. -pub type MessageKey = [u8; 32]; - -/// Source blocks per seen-set shard, so no single seen account grows without bound. -pub const EPOCH_BLOCKS: u64 = 10_000; - -const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/"; -const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; -const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/"; - -/// A peer zone whose outbox a zone watches for inbound cross-zone messages. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct CrossZonePeer { - /// The peer's Bedrock channel; its 32 bytes double as the peer's zone id. - pub channel_id: ZoneId, - /// Programs on the local zone a message from this peer is allowed to target. - pub allowed_targets: Vec, - /// The peer's block-signing public key, pinned to reject blocks inscribed by - /// anyone other than that zone's sequencer. `None` skips the check (the - /// channel signer is still authenticated by the zone-sdk). - #[serde(default)] - pub expected_block_signing_pubkey: Option<[u8; 32]>, -} - -/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer -/// (verifier): the peers it reads from Bedrock and, per peer, the local programs -/// they may deliver to. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct CrossZoneConfig { - pub peers: Vec, -} - -/// A finalized outbound message observed on a peer zone, addressed to a program -/// on this zone. The watcher fills it from the peer's block; it is never -/// self-reported by a user. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct CrossZoneMessage { - pub src_zone: ZoneId, - pub src_block_id: u64, - pub src_tx_index: u32, - pub src_program_id: ProgramId, - pub target_program_id: ProgramId, - pub payload: Vec, - /// Reserved for a future source-state proof; MUST be `None` in v1. - pub l1_inclusion_witness: Option>, -} - -/// Peer and per-peer target allowlists, plus this inbox's own zone id. -#[derive( - Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, -)] -pub struct InboxConfig { - pub self_zone: ZoneId, - pub allowed_peers: BTreeMap, - pub allowed_targets: BTreeMap>, -} - -impl InboxConfig { - /// Borsh-encoded form stored in the inbox config account. - #[must_use] - pub fn to_bytes(&self) -> Vec { - borsh::to_vec(self).expect("InboxConfig serializes") - } - - /// Decodes an [`InboxConfig`] from account data. - pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { - borsh::from_slice(bytes) - } -} - -/// The replay keys seen for one `(src_zone, epoch)` shard. -#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -pub struct SeenShard(pub BTreeSet); - -impl SeenShard { - /// Decodes a shard from account data; empty data is an empty shard. - pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { - if bytes.is_empty() { - return Ok(Self::default()); - } - borsh::from_slice(bytes) - } - - #[must_use] - pub fn to_bytes(&self) -> Vec { - borsh::to_vec(self).expect("SeenShard serializes") - } - - #[must_use] - pub fn contains(&self, key: &MessageKey) -> bool { - self.0.contains(key) - } - - /// Inserts a key; returns true if it was newly inserted. - pub fn insert(&mut self, key: MessageKey) -> bool { - self.0.insert(key) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum Instruction { - /// Delivers a finalized peer message to its target program. - Dispatch(CrossZoneMessage), -} - -/// Content-addressed replay key: `(src_zone, src_block_id, src_tx_index)` hashed -/// under a domain separator. Watcher-independent and immune to proof -/// malleability, since it keys on block id plus index rather than a tx hash. -#[must_use] -pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey { - use risc0_zkvm::sha::{Impl, Sha256 as _}; - - let mut bytes = Vec::with_capacity(MESSAGE_KEY_DOMAIN.len() + 32 + 8 + 4); - bytes.extend_from_slice(&MESSAGE_KEY_DOMAIN); - bytes.extend_from_slice(src_zone); - bytes.extend_from_slice(&src_block_id.to_le_bytes()); - bytes.extend_from_slice(&src_tx_index.to_le_bytes()); - - Impl::hash_bytes(&bytes) - .as_bytes() - .try_into() - .unwrap_or_else(|_| unreachable!()) -} - -/// The config account holding the allowlists. -#[must_use] -pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId { - AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED)) -} - -/// The seen-set shard for the `(src_zone, epoch)` the message falls in. -#[must_use] -pub fn inbox_seen_shard_account_id( - inbox_id: ProgramId, - src_zone: &ZoneId, - src_block_id: u64, -) -> AccountId { - AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id)) -} - -/// Seed of the seen-shard PDA, exposed so the guest can claim the account. -#[must_use] -pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { - use risc0_zkvm::sha::{Impl, Sha256 as _}; - - let src_epoch = src_block_id / EPOCH_BLOCKS; - let mut bytes = Vec::with_capacity(INBOX_SEEN_SEED_DOMAIN.len() + 32 + 8); - bytes.extend_from_slice(&INBOX_SEEN_SEED_DOMAIN); - bytes.extend_from_slice(src_zone); - bytes.extend_from_slice(&src_epoch.to_le_bytes()); - - let seed: [u8; 32] = Impl::hash_bytes(&bytes) - .as_bytes() - .try_into() - .unwrap_or_else(|_| unreachable!()); - PdaSeed::new(seed) -} - -/// Builds the sequencer-origin dispatch transaction. Pure, so the watcher's -/// injected tx and the indexer's re-derived tx are byte-identical for the same -/// inputs (the basis of the Option B check). `target_account_ids` are the -/// inbox's chained-call targets; deriving them is target-specific. -#[cfg(feature = "host")] -#[must_use] -pub fn build_inbox_dispatch_tx( - inbox_id: ProgramId, - msg: &CrossZoneMessage, - target_account_ids: Vec, -) -> lee::PublicTransaction { - let mut account_ids = Vec::with_capacity(2 + target_account_ids.len()); - account_ids.push(inbox_config_account_id(inbox_id)); - account_ids.push(inbox_seen_shard_account_id( - inbox_id, - &msg.src_zone, - msg.src_block_id, - )); - account_ids.extend(target_account_ids); - - let message = lee::public_transaction::Message::try_new( - inbox_id, - account_ids, - vec![], - Instruction::Dispatch(msg.clone()), - ) - .expect("inbox dispatch instruction must serialize"); - - lee::PublicTransaction::new( - message, - lee::public_transaction::WitnessSet::from_raw_parts(vec![]), - ) -} - -/// The cross-zone emission fields a watcher or verifier reads off a source -/// transaction, common to every emitter program. -#[cfg(feature = "host")] -pub struct Emission { - pub target_zone: ZoneId, - pub target_program_id: ProgramId, - pub target_accounts: Vec<[u8; 32]>, - pub payload: Vec, -} - -/// Extracts the cross-zone emission from a source transaction, recognizing the -/// known emitter programs. Returns `None` for any other program. The watcher and -/// verifier both use this so they agree on what a given source tx emits. -/// -/// Option A: each emitter is decoded explicitly. The principled alternative is to -/// read the outbox PDA write, which would need re-execution of the source tx. -#[cfg(feature = "host")] -#[must_use] -pub fn extract_emission( - program_id: ProgramId, - instruction_data: &[u32], - ping_sender_id: ProgramId, - bridge_lock_id: ProgramId, -) -> Option { - if program_id == ping_sender_id { - let ping_core::SenderInstruction::Send { - target_zone, - target_program_id, - target_accounts, - payload, - .. - } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; - Some(Emission { - target_zone, - target_program_id, - target_accounts, - payload, - }) - } else if program_id == bridge_lock_id { - let bridge_lock_core::Instruction::Lock { - target_zone, - target_program_id, - target_accounts, - payload, - .. - } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; - Some(Emission { - target_zone, - target_program_id, - target_accounts, - payload, - }) - } else { - None - } -} - -/// Builds the dispatch transaction for one peer emission. Both the sequencer's -/// watcher and the indexer's verifier go through this so their transactions are -/// byte-identical for the same emission (the basis of the Option B check). -#[cfg(feature = "host")] -#[must_use] -pub fn build_dispatch_from_emission( - inbox_id: ProgramId, - src_zone: ZoneId, - src_block_id: u64, - src_tx_index: u32, - src_program_id: ProgramId, - target_program_id: ProgramId, - target_accounts: &[[u8; 32]], - payload: Vec, -) -> lee::PublicTransaction { - let msg = CrossZoneMessage { - src_zone, - src_block_id, - src_tx_index, - src_program_id, - target_program_id, - payload, - l1_inclusion_witness: None, - }; - let target_ids = target_accounts - .iter() - .copied() - .map(AccountId::new) - .collect(); - build_inbox_dispatch_tx(inbox_id, &msg, target_ids) -} - -/// Builds the inbox config account a zone seeds into genesis state so the inbox -/// guest can authorize inbound peer messages. The sequencer and indexer seed the -/// same account from the same config, keeping their replayed state consistent. -#[cfg(feature = "host")] -#[must_use] -pub fn build_inbox_config_account( - self_zone: ZoneId, - cross_zone: &CrossZoneConfig, -) -> (AccountId, lee_core::account::Account) { - let inbox_id = lee::program::Program::cross_zone_inbox().id(); - - let mut allowed_targets = BTreeMap::new(); - for peer in &cross_zone.peers { - allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone()); - } - let config = InboxConfig { - self_zone, - allowed_peers: BTreeMap::new(), - allowed_targets, - }; - - let account = lee_core::account::Account { - program_owner: inbox_id, - balance: 0, - data: config - .to_bytes() - .try_into() - .expect("inbox config fits in account data"), - nonce: 0_u128.into(), - }; - (inbox_config_account_id(inbox_id), account) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn zone(b: u8) -> ZoneId { - [b; 32] - } - - #[test] - fn message_key_is_stable_and_content_addressed() { - assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); - assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3)); - assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3)); - assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4)); - } - - #[test] - fn seen_shards_split_on_epoch_boundary() { - let id: ProgramId = [9; 8]; - assert_eq!( - inbox_seen_shard_account_id(id, &zone(1), 0), - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), - ); - assert_ne!( - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS), - ); - } - - #[cfg(feature = "host")] - #[test] - fn build_inbox_dispatch_tx_is_deterministic() { - let inbox: ProgramId = [5; 8]; - let msg = CrossZoneMessage { - src_zone: zone(1), - src_block_id: 42, - src_tx_index: 2, - src_program_id: [6; 8], - target_program_id: [7; 8], - payload: vec![1, 2, 3, 4], - l1_inclusion_witness: None, - }; - let targets = vec![AccountId::new([8; 32]), AccountId::new([9; 32])]; - - let tx1 = build_inbox_dispatch_tx(inbox, &msg, targets.clone()); - let tx2 = build_inbox_dispatch_tx(inbox, &msg, targets); - assert_eq!(tx1, tx2); - } -} diff --git a/programs/cross_zone_outbox/core/Cargo.toml b/programs/cross_zone_outbox/core/Cargo.toml deleted file mode 100644 index c7876286..00000000 --- a/programs/cross_zone_outbox/core/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "cross_zone_outbox_core" -version = "0.1.0" -edition = "2024" -license = { workspace = true } - -[lints] -workspace = true - -[dependencies] -lee_core.workspace = true -serde = { workspace = true, features = ["alloc"] } -risc0-zkvm.workspace = true -borsh.workspace = true diff --git a/programs/cross_zone_outbox/core/src/lib.rs b/programs/cross_zone_outbox/core/src/lib.rs deleted file mode 100644 index b3449598..00000000 --- a/programs/cross_zone_outbox/core/src/lib.rs +++ /dev/null @@ -1,93 +0,0 @@ -use borsh::{BorshDeserialize, BorshSerialize}; -use lee_core::{ - account::AccountId, - program::{PdaSeed, ProgramId}, -}; -use serde::{Deserialize, Serialize}; - -/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. -pub type ZoneId = [u8; 32]; - -const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/"; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum Instruction { - /// Records an outbound cross-zone message as a write to a self-owned PDA. - /// - /// Required accounts (1): - /// - Outbox PDA account - Emit { - target_zone: ZoneId, - target_program_id: ProgramId, - /// Accounts the destination inbox must hand to the target program's - /// chained call. The emitter specifies them; the watcher forwards them - /// verbatim so the inbox stays target-agnostic. - target_accounts: Vec<[u8; 32]>, - payload: Vec, - ordinal: u32, - }, -} - -/// The message as stored in an outbox PDA. The destination zone's watcher reads -/// this from the inscribed block; the source coordinates are filled by the -/// watcher, not stored here. -#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -pub struct OutboxRecord { - pub target_zone: ZoneId, - pub target_program_id: ProgramId, - pub target_accounts: Vec<[u8; 32]>, - pub payload: Vec, -} - -impl OutboxRecord { - /// Borsh-encoded form stored in the outbox PDA's account data. - #[must_use] - pub fn to_bytes(&self) -> Vec { - borsh::to_vec(self).expect("OutboxRecord serializes") - } - - /// Decodes an [`OutboxRecord`] from account data. - pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { - borsh::from_slice(bytes) - } -} - -/// PDA holding one emitted message, keyed by destination zone and a per-zone -/// ordinal. -#[must_use] -pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId { - AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal)) -} - -/// Seed of an outbox message PDA, exposed so the guest can claim the account. -#[must_use] -pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { - use risc0_zkvm::sha::{Impl, Sha256 as _}; - - let mut bytes = Vec::with_capacity(OUTBOX_SEED_DOMAIN.len() + target_zone.len() + 4); - bytes.extend_from_slice(&OUTBOX_SEED_DOMAIN); - bytes.extend_from_slice(target_zone); - bytes.extend_from_slice(&ordinal.to_le_bytes()); - - let seed: [u8; 32] = Impl::hash_bytes(&bytes) - .as_bytes() - .try_into() - .unwrap_or_else(|_| unreachable!()); - PdaSeed::new(seed) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn outbox_pda_is_unique_per_zone_and_ordinal() { - let id: ProgramId = [3; 8]; - let zone_a = [1; 32]; - let zone_b = [2; 32]; - - assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0)); - assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1)); - assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0)); - } -} diff --git a/programs/ping/core/Cargo.toml b/programs/ping/core/Cargo.toml deleted file mode 100644 index 29870630..00000000 --- a/programs/ping/core/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "ping_core" -version = "0.1.0" -edition = "2024" -license = { workspace = true } - -[lints] -workspace = true - -[dependencies] -lee_core.workspace = true -serde = { workspace = true, features = ["alloc"] } diff --git a/programs/ping/core/src/lib.rs b/programs/ping/core/src/lib.rs deleted file mode 100644 index 268e5b98..00000000 --- a/programs/ping/core/src/lib.rs +++ /dev/null @@ -1,38 +0,0 @@ -use lee_core::{ - account::AccountId, - program::{PdaSeed, ProgramId}, -}; -use serde::{Deserialize, Serialize}; - -const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/"; - -/// Instruction delivered to `ping_receiver` by the inbox: record the payload. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum ReceiverInstruction { - Record { payload: Vec }, -} - -/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum SenderInstruction { - Send { - outbox_program_id: ProgramId, - target_zone: [u8; 32], - target_program_id: ProgramId, - target_accounts: Vec<[u8; 32]>, - payload: Vec, - ordinal: u32, - }, -} - -/// The account a `ping_receiver` records the latest delivered payload into. -#[must_use] -pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId { - AccountId::for_public_pda(&receiver_id, &ping_record_seed()) -} - -/// Seed of the record PDA, exposed so the guest can claim the account. -#[must_use] -pub fn ping_record_seed() -> PdaSeed { - PdaSeed::new(PING_RECORD_SEED) -} diff --git a/programs/wrapped_token/core/Cargo.toml b/programs/wrapped_token/core/Cargo.toml deleted file mode 100644 index ef0aabbc..00000000 --- a/programs/wrapped_token/core/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "wrapped_token_core" -version = "0.1.0" -edition = "2024" -license = { workspace = true } - -[lints] -workspace = true - -[dependencies] -lee_core.workspace = true -serde = { workspace = true, features = ["alloc"] } -risc0-zkvm.workspace = true diff --git a/programs/wrapped_token/core/src/lib.rs b/programs/wrapped_token/core/src/lib.rs deleted file mode 100644 index ea4ffd97..00000000 --- a/programs/wrapped_token/core/src/lib.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Core types for the wrapped-token program, the destination side of the -//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces -//! this by reading the authorized minter from a genesis-seeded config account. - -use lee_core::{ - account::AccountId, - program::{PdaSeed, ProgramId}, -}; -use serde::{Deserialize, Serialize}; - -const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; -const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000"; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum Instruction { - /// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by - /// the cross-zone inbox. - /// - /// Required accounts (2): the wrapped-token config PDA, then the recipient's - /// holding PDA. - Mint { recipient: [u8; 32], amount: u128 }, -} - -/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at -/// genesis so the guest can pin its caller without importing the inbox image id. -#[must_use] -pub fn config_account_id(wrapped_token_id: ProgramId) -> AccountId { - AccountId::for_public_pda(&wrapped_token_id, &config_seed()) -} - -#[must_use] -pub fn config_seed() -> PdaSeed { - PdaSeed::new(CONFIG_SEED_DOMAIN) -} - -/// PDA holding one recipient's wrapped-token balance. -#[must_use] -pub fn holding_account_id(wrapped_token_id: ProgramId, recipient: &[u8; 32]) -> AccountId { - AccountId::for_public_pda(&wrapped_token_id, &holding_seed(recipient)) -} - -#[must_use] -pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed { - use risc0_zkvm::sha::{Impl, Sha256 as _}; - - let mut bytes = Vec::with_capacity(HOLDING_SEED_DOMAIN.len() + recipient.len()); - bytes.extend_from_slice(&HOLDING_SEED_DOMAIN); - bytes.extend_from_slice(recipient); - let seed: [u8; 32] = Impl::hash_bytes(&bytes) - .as_bytes() - .try_into() - .unwrap_or_else(|_| unreachable!()); - PdaSeed::new(seed) -} - -/// Encodes the authorized minter program id for the config account's data. -#[must_use] -pub fn minter_bytes(minter: ProgramId) -> [u8; 32] { - let mut bytes = [0_u8; 32]; - for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) { - chunk.copy_from_slice(&word.to_le_bytes()); - } - bytes -} - -/// Decodes the authorized minter program id from the config account's data. -#[must_use] -pub fn read_minter(data: &[u8]) -> Option { - if data.len() < 32 { - return None; - } - let mut minter = [0_u32; 8]; - for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) { - *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); - } - Some(minter) -} - -/// Reads a wrapped-token balance from account data; empty data is a zero balance. -#[must_use] -pub fn read_balance(data: &[u8]) -> u128 { - if data.len() < 16 { - return 0; - } - u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!())) -} - -#[must_use] -pub fn balance_bytes(amount: u128) -> [u8; 16] { - amount.to_le_bytes() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn minter_round_trips() { - let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8]; - assert_eq!(read_minter(&minter_bytes(minter)), Some(minter)); - } - - #[test] - fn balance_round_trips() { - assert_eq!(read_balance(&balance_bytes(42)), 42); - assert_eq!(read_balance(&[]), 0); - } - - #[test] - fn holding_is_unique_per_recipient() { - let id: ProgramId = [9; 8]; - assert_ne!( - holding_account_id(id, &[1; 32]), - holding_account_id(id, &[2; 32]) - ); - assert_eq!( - holding_account_id(id, &[1; 32]), - holding_account_id(id, &[1; 32]) - ); - } -} diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 5ff7bfd9..f24d2f4f 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index 1606c201..e9205d97 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -442,7 +442,9 @@ async fn poll_finality(state: Arc) { fn decode_inbox_text(instruction_data: &[u32]) -> Option { let instruction: Instruction = risc0_zkvm::serde::from_slice::(instruction_data).ok()?; - let Instruction::Dispatch(message) = instruction; + let Instruction::Dispatch(message) = instruction else { + return None; + }; decode_payload(&message.payload) }