diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a075d77..1b6250be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,14 @@ jobs: with: image: ${{ needs.ci-image.outputs.image }} env: RISC0_DEV_MODE=1 - run: cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager + run: | + for i in 1 2 3; do + cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager && break + echo "::warning:: Attempt $i failed, cleaning up and retrying..." >&2 + rm -rf target/debug/deps/*.o target/debug/incremental 2>/dev/null || true + cargo clean -p integration_tests 2>/dev/null || true + sleep 5 + done - name: Upload integration test archive uses: actions/upload-artifact@v4 diff --git a/Cargo.lock b/Cargo.lock index 634cd99e..8f8344f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4372,7 +4372,6 @@ name = "integration_tests" version = "0.1.0" dependencies = [ "anyhow", - "associated_token_account_core", "authenticated_transfer_core", "bridge_core", "bridge_lock_core", @@ -4402,7 +4401,6 @@ dependencies = [ "test_fixtures", "test_programs", "testnet_initial_state", - "token_core", "tokio", "vault_core", "wallet", diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 4d078550..225d75d4 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -17,8 +17,6 @@ wallet.workspace = true common.workspace = true key_protocol.workspace = true serde_json.workspace = true -token_core.workspace = true -associated_token_account_core.workspace = true vault_core.workspace = true faucet_core.workspace = true bridge_core.workspace = true diff --git a/integration_tests/tests/amm.rs b/integration_tests/tests/amm.rs deleted file mode 100644 index 894d787c..00000000 --- a/integration_tests/tests/amm.rs +++ /dev/null @@ -1,386 +0,0 @@ -#![expect( - clippy::shadow_unrelated, - clippy::tests_outside_test_module, - reason = "We don't care about these in tests" -)] - -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_claiming_new_account, -}; -use log::info; -use tokio::test; -use wallet::{ - account::Label, - cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, - programs::amm::AmmProgramAgnosticSubcommand, - }, -}; - -#[test] -async fn amm_public() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create new account for the token definition - let definition_account_id_1 = new_account(&mut ctx, false, None).await?; - - // Create new account for the token supply holder - let supply_account_id_1 = new_account(&mut ctx, false, None).await?; - - // Create new account for receiving a token transaction - let recipient_account_id_1 = new_account(&mut ctx, false, None).await?; - - // Create new account for the token definition - let definition_account_id_2 = new_account(&mut ctx, false, None).await?; - - // Create new account for the token supply holder - let supply_account_id_2 = new_account(&mut ctx, false, None).await?; - - // Create new account for receiving a token transaction - let recipient_account_id_2 = new_account(&mut ctx, false, None).await?; - - // Create new token - create_token( - &mut ctx, - public_mention(definition_account_id_1), - public_mention(supply_account_id_1), - "A NAM1".to_owned(), - 37, - ) - .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( - &mut ctx, - public_mention(definition_account_id_2), - public_mention(supply_account_id_2), - "A NAM2".to_owned(), - 37, - ) - .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 ==============="); - - // Create new AMM - - // Setup accounts - // Create new account for the user holding lp - let user_holding_lp = new_account(&mut ctx, false, None).await?; - - // Send creation tx - let subcommand = AmmProgramAgnosticSubcommand::New { - user_holding_a: public_mention(recipient_account_id_1), - user_holding_b: public_mention(recipient_account_id_2), - user_holding_lp: public_mention(user_holding_lp), - balance_a: 3, - balance_b: 3, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - - let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - - let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; - - assert_eq!( - u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), - 4 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_b_acc.data[33..].try_into().unwrap()), - 4 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_lp_acc.data[33..].try_into().unwrap()), - 3 - ); - - info!("=================== AMM DEFINITION FINISHED ==============="); - - // Make swap - - let subcommand = AmmProgramAgnosticSubcommand::SwapExactInput { - user_holding_a: public_mention(recipient_account_id_1), - user_holding_b: public_mention(recipient_account_id_2), - amount_in: 2, - min_amount_out: 1, - token_definition: definition_account_id_1, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - - let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - - let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; - - assert_eq!( - u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), - 2 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_b_acc.data[33..].try_into().unwrap()), - 5 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_lp_acc.data[33..].try_into().unwrap()), - 3 - ); - - info!("=================== FIRST SWAP FINISHED ==============="); - - // Make swap - - let subcommand = AmmProgramAgnosticSubcommand::SwapExactInput { - user_holding_a: public_mention(recipient_account_id_1), - user_holding_b: public_mention(recipient_account_id_2), - amount_in: 2, - min_amount_out: 1, - token_definition: definition_account_id_2, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - - let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - - let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; - - assert_eq!( - u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), - 4 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_b_acc.data[33..].try_into().unwrap()), - 3 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_lp_acc.data[33..].try_into().unwrap()), - 3 - ); - - info!("=================== SECOND SWAP FINISHED ==============="); - - // Add liquidity - - let subcommand = AmmProgramAgnosticSubcommand::AddLiquidity { - user_holding_a: public_mention(recipient_account_id_1), - user_holding_b: public_mention(recipient_account_id_2), - user_holding_lp: public_mention(user_holding_lp), - min_amount_lp: 1, - max_amount_a: 2, - max_amount_b: 2, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - - let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - - let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; - - assert_eq!( - u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), - 3 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_b_acc.data[33..].try_into().unwrap()), - 1 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_lp_acc.data[33..].try_into().unwrap()), - 4 - ); - - info!("=================== ADD LIQ FINISHED ==============="); - - // Remove liquidity - - let subcommand = AmmProgramAgnosticSubcommand::RemoveLiquidity { - user_holding_a: public_mention(recipient_account_id_1), - user_holding_b: public_mention(recipient_account_id_2), - user_holding_lp: public_mention(user_holding_lp), - balance_lp: 2, - min_amount_a: 1, - min_amount_b: 1, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - - let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - - let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; - - assert_eq!( - u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), - 5 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_b_acc.data[33..].try_into().unwrap()), - 4 - ); - - assert_eq!( - u128::from_le_bytes(user_holding_lp_acc.data[33..].try_into().unwrap()), - 2 - ); - - info!("Success!"); - - Ok(()) -} - -#[test] -async fn amm_new_pool_using_labels() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create token 1 accounts - let definition_account_id_1 = new_account(&mut ctx, false, None).await?; - - let supply_account_id_1 = new_account(&mut ctx, false, None).await?; - - // Create holding_a with a label - let holding_a_label = Label::new("amm-holding-a-label"); - let SubcommandReturnValue::RegisterAccount { - account_id: holding_a_id, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: Some(Label::new(holding_a_label.clone())), - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - - // Create token 2 accounts - let definition_account_id_2 = new_account(&mut ctx, false, None).await?; - - let supply_account_id_2 = new_account(&mut ctx, false, None).await?; - - // Create holding_b with a label - let holding_b_label = Label::new("amm-holding-b-label"); - let SubcommandReturnValue::RegisterAccount { - account_id: holding_b_id, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: Some(Label::new(holding_b_label.clone())), - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - - // Create holding_lp with a label - let holding_lp_label = Label::new("amm-holding-lp-label"); - let SubcommandReturnValue::RegisterAccount { - account_id: holding_lp_id, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: Some(Label::new(holding_lp_label.clone())), - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - - // Create token 1 and distribute to holding_a - create_token( - &mut ctx, - public_mention(definition_account_id_1), - public_mention(supply_account_id_1), - "TOKEN1".to_owned(), - 10, - ) - .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( - &mut ctx, - public_mention(definition_account_id_2), - public_mention(supply_account_id_2), - "TOKEN2".to_owned(), - 10, - ) - .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 { - user_holding_a: holding_a_label.into(), - user_holding_b: holding_b_label.into(), - user_holding_lp: holding_lp_label.into(), - balance_a: 3, - balance_b: 3, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let holding_lp_acc = get_account(&ctx, holding_lp_id).await?; - - // LP balance should be 3 (geometric mean of 3, 3) - assert_eq!( - u128::from_le_bytes(holding_lp_acc.data[33..].try_into().unwrap()), - 3 - ); - - info!("Successfully created AMM pool using account labels"); - - Ok(()) -} diff --git a/integration_tests/tests/ata.rs b/integration_tests/tests/ata.rs deleted file mode 100644 index 21905fb9..00000000 --- a/integration_tests/tests/ata.rs +++ /dev/null @@ -1,562 +0,0 @@ -#![expect( - clippy::shadow_unrelated, - clippy::tests_outside_test_module, - reason = "We don't care about these in tests" -)] - -use std::time::Duration; - -use anyhow::{Context as _, Result}; -use associated_token_account_core::{compute_ata_seed, get_associated_token_account_id}; -use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, create_token, get_account, new_account, - private_mention, public_mention, token_send, verify_commitment_is_in_state, -}; -use log::info; -use sequencer_service_rpc::RpcClient as _; -use token_core::{TokenDefinition, TokenHolding}; -use tokio::test; -use wallet::cli::{Command, programs::ata::AtaSubcommand}; - -#[test] -async fn create_ata_initializes_holding_account() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let definition_account_id = new_account(&mut ctx, false, None).await?; - let supply_account_id = new_account(&mut ctx, false, None).await?; - let owner_account_id = new_account(&mut ctx, false, None).await?; - - // Create a fungible token - let total_supply = 100_u128; - create_token( - &mut ctx, - public_mention(definition_account_id), - public_mention(supply_account_id), - "TEST".to_owned(), - total_supply, - ) - .await?; - - // Create the ATA for owner + definition - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: public_mention(owner_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Derive expected ATA address and check on-chain state - let ata_program_id = programs::ata().id(); - let ata_id = get_associated_token_account_id( - &ata_program_id, - &compute_ata_seed(owner_account_id, definition_account_id), - ); - - let ata_acc = ctx - .sequencer_client() - .get_account(ata_id) - .await - .context("ATA account not found")?; - - assert_eq!(ata_acc.program_owner, programs::token().id()); - let holding = TokenHolding::try_from(&ata_acc.data)?; - assert_eq!( - holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: 0, - } - ); - - Ok(()) -} - -#[test] -async fn create_ata_is_idempotent() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let definition_account_id = new_account(&mut ctx, false, None).await?; - let supply_account_id = new_account(&mut ctx, false, None).await?; - let owner_account_id = new_account(&mut ctx, false, None).await?; - - // Create a fungible token - create_token( - &mut ctx, - public_mention(definition_account_id), - public_mention(supply_account_id), - "TEST".to_owned(), - 100, - ) - .await?; - - // Create the ATA once - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: public_mention(owner_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Create the ATA a second time — must succeed (idempotent) - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: public_mention(owner_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // State must be unchanged - let ata_program_id = programs::ata().id(); - let ata_id = get_associated_token_account_id( - &ata_program_id, - &compute_ata_seed(owner_account_id, definition_account_id), - ); - - let ata_acc = ctx - .sequencer_client() - .get_account(ata_id) - .await - .context("ATA account not found")?; - - assert_eq!(ata_acc.program_owner, programs::token().id()); - let holding = TokenHolding::try_from(&ata_acc.data)?; - assert_eq!( - holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: 0, - } - ); - - Ok(()) -} - -#[test] -async fn transfer_and_burn_via_ata() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let definition_account_id = new_account(&mut ctx, false, None).await?; - let supply_account_id = new_account(&mut ctx, false, None).await?; - let sender_account_id = new_account(&mut ctx, false, None).await?; - let recipient_account_id = new_account(&mut ctx, false, None).await?; - - let total_supply = 1000_u128; - - // Create a fungible token, supply goes to supply_account_id - create_token( - &mut ctx, - public_mention(definition_account_id), - public_mention(supply_account_id), - "TEST".to_owned(), - total_supply, - ) - .await?; - - // Derive ATA addresses - let ata_program_id = programs::ata().id(); - let sender_ata_id = get_associated_token_account_id( - &ata_program_id, - &compute_ata_seed(sender_account_id, definition_account_id), - ); - let recipient_ata_id = get_associated_token_account_id( - &ata_program_id, - &compute_ata_seed(recipient_account_id, definition_account_id), - ); - - // Create ATAs for sender and recipient - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: public_mention(sender_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: public_mention(recipient_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Fund sender's ATA from the supply account (direct token transfer) - let fund_amount = 200_u128; - token_send( - &mut ctx, - public_mention(supply_account_id), - public_mention(sender_ata_id), - fund_amount, - ) - .await?; - - // Transfer from sender's ATA to recipient's ATA via the ATA program - let transfer_amount = 50_u128; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Send { - from: public_mention(sender_account_id), - token_definition: definition_account_id, - to: recipient_ata_id, - amount: transfer_amount, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Verify sender ATA balance decreased - let sender_ata_acc = get_account(&ctx, sender_ata_id).await?; - let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?; - assert_eq!( - sender_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: fund_amount - transfer_amount, - } - ); - - // Verify recipient ATA balance increased - let recipient_ata_acc = get_account(&ctx, recipient_ata_id).await?; - let recipient_holding = TokenHolding::try_from(&recipient_ata_acc.data)?; - assert_eq!( - recipient_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount, - } - ); - - // Burn from sender's ATA - let burn_amount = 30_u128; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Burn { - holder: public_mention(sender_account_id), - token_definition: definition_account_id, - amount: burn_amount, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Verify sender ATA balance after burn - let sender_ata_acc = get_account(&ctx, sender_ata_id).await?; - let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?; - assert_eq!( - sender_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: fund_amount - transfer_amount - burn_amount, - } - ); - - // Verify the token definition total_supply decreased by burn_amount - let definition_acc = get_account(&ctx, definition_account_id).await?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name: "TEST".to_owned(), - total_supply: total_supply - burn_amount, - metadata_id: None, - } - ); - - Ok(()) -} - -#[test] -async fn create_ata_with_private_owner() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let definition_account_id = new_account(&mut ctx, false, None).await?; - let supply_account_id = new_account(&mut ctx, false, None).await?; - let owner_account_id = new_account(&mut ctx, true, None).await?; - - // Create a fungible token - create_token( - &mut ctx, - public_mention(definition_account_id), - public_mention(supply_account_id), - "TEST".to_owned(), - 100, - ) - .await?; - - // Create the ATA for the private owner + definition - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: private_mention(owner_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Derive expected ATA address and check on-chain state - let ata_program_id = programs::ata().id(); - let ata_id = get_associated_token_account_id( - &ata_program_id, - &compute_ata_seed(owner_account_id, definition_account_id), - ); - - let ata_acc = ctx - .sequencer_client() - .get_account(ata_id) - .await - .context("ATA account not found")?; - - assert_eq!(ata_acc.program_owner, programs::token().id()); - let holding = TokenHolding::try_from(&ata_acc.data)?; - assert_eq!( - holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: 0, - } - ); - - // Verify the private owner's commitment is in state - let commitment = ctx - .wallet() - .get_private_account_commitment(owner_account_id) - .context("Private owner commitment not found")?; - assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); - - Ok(()) -} - -#[test] -async fn transfer_via_ata_private_owner() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let definition_account_id = new_account(&mut ctx, false, None).await?; - let supply_account_id = new_account(&mut ctx, false, None).await?; - let sender_account_id = new_account(&mut ctx, true, None).await?; - let recipient_account_id = new_account(&mut ctx, false, None).await?; - - let total_supply = 1000_u128; - - // Create a fungible token - create_token( - &mut ctx, - public_mention(definition_account_id), - public_mention(supply_account_id), - "TEST".to_owned(), - total_supply, - ) - .await?; - - // Derive ATA addresses - let ata_program_id = programs::ata().id(); - let sender_ata_id = get_associated_token_account_id( - &ata_program_id, - &compute_ata_seed(sender_account_id, definition_account_id), - ); - let recipient_ata_id = get_associated_token_account_id( - &ata_program_id, - &compute_ata_seed(recipient_account_id, definition_account_id), - ); - - // Create ATAs for sender (private owner) and recipient (public owner) - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: private_mention(sender_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: public_mention(recipient_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Fund sender's ATA from the supply account (direct token transfer) - let fund_amount = 200_u128; - token_send( - &mut ctx, - public_mention(supply_account_id), - public_mention(sender_ata_id), - fund_amount, - ) - .await?; - - // Transfer from sender's ATA (private owner) to recipient's ATA - let transfer_amount = 50_u128; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Send { - from: private_mention(sender_account_id), - token_definition: definition_account_id, - to: recipient_ata_id, - amount: transfer_amount, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Verify sender ATA balance decreased - let sender_ata_acc = get_account(&ctx, sender_ata_id).await?; - let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?; - assert_eq!( - sender_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: fund_amount - transfer_amount, - } - ); - - // Verify recipient ATA balance increased - let recipient_ata_acc = get_account(&ctx, recipient_ata_id).await?; - let recipient_holding = TokenHolding::try_from(&recipient_ata_acc.data)?; - assert_eq!( - recipient_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount, - } - ); - - // Verify the private sender's commitment is in state - let commitment = ctx - .wallet() - .get_private_account_commitment(sender_account_id) - .context("Private sender commitment not found")?; - assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); - - Ok(()) -} - -#[test] -async fn burn_via_ata_private_owner() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let definition_account_id = new_account(&mut ctx, false, None).await?; - let supply_account_id = new_account(&mut ctx, false, None).await?; - let holder_account_id = new_account(&mut ctx, true, None).await?; - - let total_supply = 500_u128; - - // Create a fungible token - create_token( - &mut ctx, - public_mention(definition_account_id), - public_mention(supply_account_id), - "TEST".to_owned(), - total_supply, - ) - .await?; - - // Derive holder's ATA address - let ata_program_id = programs::ata().id(); - let holder_ata_id = get_associated_token_account_id( - &ata_program_id, - &compute_ata_seed(holder_account_id, definition_account_id), - ); - - // Create ATA for the private holder - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Create { - owner: private_mention(holder_account_id), - token_definition: definition_account_id, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Fund holder's ATA from the supply account - let fund_amount = 300_u128; - token_send( - &mut ctx, - public_mention(supply_account_id), - public_mention(holder_ata_id), - fund_amount, - ) - .await?; - - // Burn from holder's ATA (private owner) - let burn_amount = 100_u128; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Ata(AtaSubcommand::Burn { - holder: private_mention(holder_account_id), - token_definition: definition_account_id, - amount: burn_amount, - }), - ) - .await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Verify holder ATA balance after burn - let holder_ata_acc = get_account(&ctx, holder_ata_id).await?; - let holder_holding = TokenHolding::try_from(&holder_ata_acc.data)?; - assert_eq!( - holder_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: fund_amount - burn_amount, - } - ); - - // Verify the token definition total_supply decreased by burn_amount - let definition_acc = get_account(&ctx, definition_account_id).await?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name: "TEST".to_owned(), - total_supply: total_supply - burn_amount, - metadata_id: None, - } - ); - - // Verify the private holder's commitment is in state - let commitment = ctx - .wallet() - .get_private_account_commitment(holder_account_id) - .context("Private holder commitment not found")?; - assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); - - Ok(()) -} diff --git a/integration_tests/tests/pinata.rs b/integration_tests/tests/pinata.rs deleted file mode 100644 index f2c634a2..00000000 --- a/integration_tests/tests/pinata.rs +++ /dev/null @@ -1,239 +0,0 @@ -#![expect( - clippy::shadow_unrelated, - clippy::tests_outside_test_module, - reason = "We don't care about these in tests" -)] - -use std::time::Duration; - -use anyhow::{Context as _, Result}; -use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, new_account, private_mention, - public_mention, sync_private, verify_commitment_is_in_state, wait_for_indexer_to_catch_up, -}; -use log::info; -use tokio::test; -use wallet::cli::{ - Command, SubcommandReturnValue, - programs::{ - native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, - }, -}; - -#[test] -async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let winner_account_id = new_account(&mut ctx, false, None).await?; - - let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - let claim_result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: public_mention(winner_account_id), - }), - ) - .await; - - assert!( - claim_result.is_err(), - "Expected uninitialized account error" - ); - let err = claim_result.unwrap_err().to_string(); - assert!( - err.contains("wallet auth-transfer init --account-id Public/"), - "Expected init guidance, got: {err}", - ); - - let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - assert_eq!(pinata_balance_post, pinata_balance_pre); - - Ok(()) -} - -#[test] -async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let winner_account_id = new_account(&mut ctx, true, None).await?; - - let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - let claim_result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: private_mention(winner_account_id), - }), - ) - .await; - - assert!( - claim_result.is_err(), - "Expected uninitialized account error" - ); - let err = claim_result.unwrap_err().to_string(); - assert!( - err.contains("wallet auth-transfer init --account-id Private/"), - "Expected init guidance, got: {err}", - ); - - let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - assert_eq!(pinata_balance_post, pinata_balance_pre); - - Ok(()) -} - -#[test] -async fn claim_pinata_to_existing_public_account() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let pinata_prize = 150; - let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: public_mention(ctx.existing_public_accounts()[0]), - }); - - let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - info!("Checking correct balance move"); - let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - let winner_balance_post = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; - - assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize); - assert_eq!(winner_balance_post, 10000 + pinata_prize); - - info!("Successfully claimed pinata to public account"); - - Ok(()) -} - -#[test] -async fn claim_pinata_indexer_keeps_up() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: public_mention(ctx.existing_public_accounts()[0]), - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - info!("Waiting for indexer to parse blocks"); - wait_for_indexer_to_catch_up(&ctx).await?; - - let winner_ind_state = indexer_service_rpc::RpcClient::get_account( - &**ctx.indexer_client(), - ctx.existing_public_accounts()[0].into(), - ) - .await - .unwrap(); - let winner_seq_state = sequencer_service_rpc::RpcClient::get_account( - ctx.sequencer_client(), - ctx.existing_public_accounts()[0], - ) - .await?; - - assert_eq!(winner_ind_state, winner_seq_state.into()); - - info!("Indexer correctly indexed the pinata claim"); - - Ok(()) -} - -#[test] -async fn claim_pinata_to_existing_private_account() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let pinata_prize = 150; - let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: private_mention(ctx.existing_private_accounts()[0]), - }); - - let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - 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; - - info!("Syncing private accounts"); - sync_private(&mut ctx).await?; - - let new_commitment = ctx - .wallet() - .get_private_account_commitment(ctx.existing_private_accounts()[0]) - .context("Failed to get private account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - - let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize); - - info!("Successfully claimed pinata to existing private account"); - - Ok(()) -} - -#[test] -async fn claim_pinata_to_new_private_account() -> Result<()> { - let mut ctx = TestContext::new().await?; - - let pinata_prize = 150; - - // Create new private account - let winner_account_id = new_account(&mut ctx, true, None).await?; - - // Initialize account under auth transfer program - let command = Command::AuthTransfer(AuthTransferSubcommand::Init { - account_id: private_mention(winner_account_id), - }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let new_commitment = ctx - .wallet() - .get_private_account_commitment(winner_account_id) - .context("Failed to get private account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - - // Claim pinata to the new private account - let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: private_mention(winner_account_id), - }); - - let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let new_commitment = ctx - .wallet() - .get_private_account_commitment(winner_account_id) - .context("Failed to get private account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - - let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - - assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize); - - info!("Successfully claimed pinata to new private account"); - - Ok(()) -} diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs deleted file mode 100644 index 215a1065..00000000 --- a/integration_tests/tests/token.rs +++ /dev/null @@ -1,996 +0,0 @@ -#![expect( - clippy::shadow_unrelated, - clippy::tests_outside_test_module, - reason = "We don't care about these in tests" -)] - -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, token_send_claiming_new_account, verify_commitment_is_in_state, -}; -use key_protocol::key_management::key_tree::chain_index::ChainIndex; -use log::info; -use token_core::{TokenDefinition, TokenHolding}; -use tokio::test; -use wallet::{ - account::Label, - cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, - programs::token::TokenProgramAgnosticSubcommand, - }, -}; - -#[test] -async fn create_and_transfer_public_token() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create new account for the token definition - let definition_account_id = new_account(&mut ctx, false, None).await?; - - // Create new account for the token supply holder - let supply_account_id = new_account(&mut ctx, false, None).await?; - - // Create new account for receiving a token transaction - let recipient_account_id = new_account(&mut ctx, false, None).await?; - - // Create new token - let name = "A NAME".to_owned(); - let total_supply = 37; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: name.clone(), - total_supply, - }; - 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; - - // Check the status of the token definition account - let definition_acc = get_account(&ctx, definition_account_id).await?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - - assert_eq!(definition_acc.program_owner, programs::token().id()); - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name: name.clone(), - total_supply, - metadata_id: None - } - ); - - // Check the status of the token holding account with the total supply - let supply_acc = get_account(&ctx, supply_account_id).await?; - - // The account must be owned by the token program - assert_eq!(supply_acc.program_owner, programs::token().id()); - let token_holding = TokenHolding::try_from(&supply_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: total_supply - } - ); - - // 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; - 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?; - assert_eq!(supply_acc.program_owner, programs::token().id()); - let token_holding = TokenHolding::try_from(&supply_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: total_supply - transfer_amount - } - ); - - // Check the status of the recipient account after transfer - let recipient_acc = get_account(&ctx, recipient_account_id).await?; - assert_eq!(recipient_acc.program_owner, programs::token().id()); - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount - } - ); - - // Burn 3 tokens from recipient_acc - let burn_amount = 3; - let subcommand = TokenProgramAgnosticSubcommand::Burn { - definition: public_mention(definition_account_id), - holder: public_mention(recipient_account_id), - amount: burn_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; - - // Check the status of the token definition account after burn - let definition_acc = get_account(&ctx, definition_account_id).await?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name: name.clone(), - total_supply: total_supply - burn_amount, - metadata_id: None - } - ); - - // Check the status of the recipient account after burn - let recipient_acc = get_account(&ctx, recipient_account_id).await?; - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount - burn_amount - } - ); - - // Mint 10 tokens at recipient_acc - let mint_amount = 10; - let subcommand = TokenProgramAgnosticSubcommand::Mint { - definition: public_mention(definition_account_id), - holder: Some(public_mention(recipient_account_id)), - holder_npk: None, - holder_vpk: None, - holder_keys: None, - holder_identifier: None, - amount: mint_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; - - // Check the status of the token definition account after mint - let definition_acc = get_account(&ctx, definition_account_id).await?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name, - total_supply: total_supply - burn_amount + mint_amount, - metadata_id: None - } - ); - - // Check the status of the recipient account after mint - let recipient_acc = get_account(&ctx, recipient_account_id).await?; - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount - burn_amount + mint_amount - } - ); - - info!("Successfully created and transferred public token"); - - Ok(()) -} - -#[test] -async fn create_and_transfer_token_with_private_supply() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create new account for the token definition (public) - let definition_account_id = new_account(&mut ctx, false, None).await?; - - // Create new account for the token supply holder (private) - let supply_account_id = new_account(&mut ctx, true, None).await?; - - // Create new account for receiving a token transaction (private) - let recipient_account_id = new_account(&mut ctx, true, None).await?; - - // Create new token - let name = "A NAME".to_owned(); - let total_supply = 37; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: private_mention(supply_account_id), - name: name.clone(), - total_supply, - }; - - 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; - - // Check the status of the token definition account - let definition_acc = get_account(&ctx, definition_account_id).await?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - - assert_eq!(definition_acc.program_owner, programs::token().id()); - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name: name.clone(), - total_supply, - metadata_id: None - } - ); - - let new_commitment1 = ctx - .wallet() - .get_private_account_commitment(supply_account_id) - .context("Failed to get supply account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client()).await); - - // Transfer 7 tokens from supply_acc to recipient_account_id - let transfer_amount = 7; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: private_mention(supply_account_id), - to: Some(private_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; - - let new_commitment1 = ctx - .wallet() - .get_private_account_commitment(supply_account_id) - .context("Failed to get supply account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client()).await); - - let new_commitment2 = ctx - .wallet() - .get_private_account_commitment(recipient_account_id) - .context("Failed to get recipient account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client()).await); - - // Burn 3 tokens from recipient_acc - let burn_amount = 3; - let subcommand = TokenProgramAgnosticSubcommand::Burn { - definition: public_mention(definition_account_id), - holder: private_mention(recipient_account_id), - amount: burn_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; - - // Check the token definition account after burn - let definition_acc = get_account(&ctx, definition_account_id).await?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name, - total_supply: total_supply - burn_amount, - metadata_id: None - } - ); - - let new_commitment2 = ctx - .wallet() - .get_private_account_commitment(recipient_account_id) - .context("Failed to get recipient account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client()).await); - - // Check the recipient account balance after burn - let recipient_acc = ctx - .wallet() - .get_account_private(recipient_account_id) - .context("Failed to get recipient account")?; - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount - burn_amount - } - ); - - info!("Successfully created and transferred token with private supply"); - - Ok(()) -} - -#[test] -async fn create_token_with_private_definition() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create token definition account (private) - let definition_account_id = new_account(&mut ctx, true, Some(ChainIndex::root())).await?; - - // Create supply account (public) - let supply_account_id = new_account(&mut ctx, false, Some(ChainIndex::root())).await?; - - // Create token with private definition - let name = "A NAME".to_owned(); - let total_supply = 37; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: private_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: name.clone(), - total_supply, - }; - - 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; - - // Verify private definition commitment - let new_commitment = ctx - .wallet() - .get_private_account_commitment(definition_account_id) - .context("Failed to get definition commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - - // Verify supply account - let supply_acc = get_account(&ctx, supply_account_id).await?; - - assert_eq!(supply_acc.program_owner, programs::token().id()); - let token_holding = TokenHolding::try_from(&supply_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: total_supply - } - ); - - // Create private recipient account - let recipient_account_id_private = new_account(&mut ctx, true, None).await?; - - // Create public recipient account - let recipient_account_id_public = new_account(&mut ctx, false, None).await?; - - // Mint to public account - let mint_amount_public = 10; - let subcommand = TokenProgramAgnosticSubcommand::Mint { - definition: private_mention(definition_account_id), - holder: Some(public_mention(recipient_account_id_public)), - holder_npk: None, - holder_vpk: None, - holder_keys: None, - holder_identifier: None, - amount: mint_amount_public, - }; - - 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; - - // Verify definition account has updated supply - let definition_acc = ctx - .wallet() - .get_account_private(definition_account_id) - .context("Failed to get definition account")?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name: name.clone(), - total_supply: total_supply + mint_amount_public, - metadata_id: None - } - ); - - // Verify public recipient received tokens - let recipient_acc = get_account(&ctx, recipient_account_id_public).await?; - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: mint_amount_public - } - ); - - // Mint to private account - let mint_amount_private = 5; - let subcommand = TokenProgramAgnosticSubcommand::Mint { - definition: private_mention(definition_account_id), - holder: Some(private_mention(recipient_account_id_private)), - holder_npk: None, - holder_vpk: None, - holder_keys: None, - holder_identifier: None, - amount: mint_amount_private, - }; - - 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; - - // Verify private recipient commitment - let new_commitment = ctx - .wallet() - .get_private_account_commitment(recipient_account_id_private) - .context("Failed to get recipient commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - - // Verify private recipient balance - let recipient_acc_private = ctx - .wallet() - .get_account_private(recipient_account_id_private) - .context("Failed to get private recipient account")?; - let token_holding = TokenHolding::try_from(&recipient_acc_private.data)?; - - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: mint_amount_private - } - ); - - info!("Successfully created token with private definition and minted to both account types"); - - Ok(()) -} - -#[test] -async fn create_token_with_private_definition_and_supply() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create token definition account (private) - let definition_account_id = new_account(&mut ctx, true, None).await?; - - // Create supply account (private) - let supply_account_id = new_account(&mut ctx, true, None).await?; - - // Create token with both private definition and supply - let name = "A NAME".to_owned(); - let total_supply = 37; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: private_mention(definition_account_id), - supply_account_id: private_mention(supply_account_id), - name, - total_supply, - }; - - 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; - - // Verify definition commitment - let definition_commitment = ctx - .wallet() - .get_private_account_commitment(definition_account_id) - .context("Failed to get definition commitment")?; - assert!(verify_commitment_is_in_state(definition_commitment, ctx.sequencer_client()).await); - - // Verify supply commitment - let supply_commitment = ctx - .wallet() - .get_private_account_commitment(supply_account_id) - .context("Failed to get supply commitment")?; - assert!(verify_commitment_is_in_state(supply_commitment, ctx.sequencer_client()).await); - - // Verify supply balance - let supply_acc = ctx - .wallet() - .get_account_private(supply_account_id) - .context("Failed to get supply account")?; - let token_holding = TokenHolding::try_from(&supply_acc.data)?; - - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: total_supply - } - ); - - // Create recipient account - let recipient_account_id = new_account(&mut ctx, true, None).await?; - - // Transfer tokens - let transfer_amount = 7; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: private_mention(supply_account_id), - to: Some(private_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; - - // Verify both commitments updated - let supply_commitment = ctx - .wallet() - .get_private_account_commitment(supply_account_id) - .context("Failed to get supply commitment")?; - assert!(verify_commitment_is_in_state(supply_commitment, ctx.sequencer_client()).await); - - let recipient_commitment = ctx - .wallet() - .get_private_account_commitment(recipient_account_id) - .context("Failed to get recipient commitment")?; - assert!(verify_commitment_is_in_state(recipient_commitment, ctx.sequencer_client()).await); - - // Verify balances - let supply_acc = ctx - .wallet() - .get_account_private(supply_account_id) - .context("Failed to get supply account")?; - let token_holding = TokenHolding::try_from(&supply_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: total_supply - transfer_amount - } - ); - - let recipient_acc = ctx - .wallet() - .get_account_private(recipient_account_id) - .context("Failed to get recipient account")?; - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount - } - ); - - info!("Successfully created and transferred token with both private definition and supply"); - - Ok(()) -} - -#[test] -async fn shielded_token_transfer() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create token definition account (public) - let definition_account_id = new_account(&mut ctx, false, None).await?; - - // Create supply account (public) - let supply_account_id = new_account(&mut ctx, false, None).await?; - - // Create recipient account (private) for shielded transfer - let recipient_account_id = new_account(&mut ctx, true, None).await?; - - // Create token - let name = "A NAME".to_owned(); - let total_supply = 37; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name, - total_supply, - }; - - 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; - - // Perform shielded transfer: public supply -> private recipient - let transfer_amount = 7; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id), - to: Some(private_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; - - // Verify supply account balance - let supply_acc = get_account(&ctx, supply_account_id).await?; - let token_holding = TokenHolding::try_from(&supply_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: total_supply - transfer_amount - } - ); - - // Verify recipient commitment exists - let new_commitment = ctx - .wallet() - .get_private_account_commitment(recipient_account_id) - .context("Failed to get recipient commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - - // Verify recipient balance - let recipient_acc = ctx - .wallet() - .get_account_private(recipient_account_id) - .context("Failed to get recipient account")?; - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount - } - ); - - info!("Successfully performed shielded token transfer"); - - Ok(()) -} - -#[test] -async fn deshielded_token_transfer() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create token definition account (public) - let definition_account_id = new_account(&mut ctx, false, None).await?; - - // Create supply account (private) - let supply_account_id = new_account(&mut ctx, true, None).await?; - - // Create recipient account (public) for deshielded transfer - let recipient_account_id = new_account(&mut ctx, false, None).await?; - - // Create token with private supply - let name = "A NAME".to_owned(); - let total_supply = 37; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: private_mention(supply_account_id), - name, - total_supply, - }; - - 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; - - // Perform deshielded transfer: private supply -> public recipient - let transfer_amount = 7; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: private_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; - - // Verify supply account commitment exists - let new_commitment = ctx - .wallet() - .get_private_account_commitment(supply_account_id) - .context("Failed to get supply commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - - // Verify supply balance - let supply_acc = ctx - .wallet() - .get_account_private(supply_account_id) - .context("Failed to get supply account")?; - let token_holding = TokenHolding::try_from(&supply_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: total_supply - transfer_amount - } - ); - - // Verify recipient balance - let recipient_acc = get_account(&ctx, recipient_account_id).await?; - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount - } - ); - - info!("Successfully performed deshielded token transfer"); - - Ok(()) -} - -#[test] -async fn token_claiming_path_with_private_accounts() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create token definition account (private) - let definition_account_id = new_account(&mut ctx, true, None).await?; - - // Create supply account (private) - let supply_account_id = new_account(&mut ctx, true, None).await?; - - // Create token - let name = "A NAME".to_owned(); - let total_supply = 37; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: private_mention(definition_account_id), - supply_account_id: private_mention(supply_account_id), - name, - total_supply, - }; - - 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; - - // Create new private account for claiming path - let recipient_account_id = new_account(&mut ctx, true, None).await?; - - // Get keys for foreign mint (claiming path) - let holder = ctx - .wallet() - .storage() - .key_chain() - .private_account(recipient_account_id) - .context("Failed to get private account keys")?; - - let holder_keys = holder.key_chain; - let holder_identifier = holder.kind.identifier(); - - // Mint using claiming path (foreign account) - let mint_amount = 9; - let subcommand = TokenProgramAgnosticSubcommand::Mint { - definition: private_mention(definition_account_id), - holder: None, - holder_npk: Some(hex::encode(holder_keys.nullifier_public_key.0)), - holder_vpk: Some(hex::encode(holder_keys.viewing_public_key.to_bytes())), - holder_keys: None, - holder_identifier: Some(holder_identifier), - amount: mint_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; - - // Sync to claim the account - sync_private(&mut ctx).await?; - - // Verify commitment exists - let recipient_commitment = ctx - .wallet() - .get_private_account_commitment(recipient_account_id) - .context("Failed to get recipient commitment")?; - assert!(verify_commitment_is_in_state(recipient_commitment, ctx.sequencer_client()).await); - - // Verify balance - let recipient_acc = ctx - .wallet() - .get_account_private(recipient_account_id) - .context("Failed to get recipient account")?; - let token_holding = TokenHolding::try_from(&recipient_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: mint_amount - } - ); - - info!("Successfully minted tokens using claiming path"); - - Ok(()) -} - -#[test] -async fn create_token_using_labels() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create definition and supply accounts with labels - let def_label = Label::new("token-definition-label"); - let supply_label = Label::new("token-supply-label"); - - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: Some(def_label.clone()), - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: Some(Label::new(supply_label.clone())), - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - - // Create token using account labels instead of IDs - let name = "LABELED TOKEN".to_owned(); - let total_supply = 100; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: def_label.into(), - supply_account_id: supply_label.into(), - name: name.clone(), - total_supply, - }; - 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; - - let definition_acc = get_account(&ctx, definition_account_id).await?; - let token_definition = TokenDefinition::try_from(&definition_acc.data)?; - - assert_eq!(definition_acc.program_owner, programs::token().id()); - assert_eq!( - token_definition, - TokenDefinition::Fungible { - name, - total_supply, - metadata_id: None - } - ); - - let supply_acc = get_account(&ctx, supply_account_id).await?; - let token_holding = TokenHolding::try_from(&supply_acc.data)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: total_supply - } - ); - - info!("Successfully created token using definition and supply account labels"); - - Ok(()) -} - -#[test] -async fn transfer_token_using_from_label() -> Result<()> { - let mut ctx = TestContext::new().await?; - - // Create definition account - let definition_account_id = new_account(&mut ctx, false, None).await?; - - // Create supply account with a label - let supply_label = Label::new("token-supply-sender"); - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: Some(supply_label.clone()), - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - - // Create recipient account - let recipient_account_id = new_account(&mut ctx, false, None).await?; - - // Create token - let total_supply = 50; - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: "LABEL TEST TOKEN".to_owned(), - total_supply, - }; - 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; - - // 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) - ); - - // 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)?; - assert_eq!( - token_holding, - TokenHolding::Fungible { - definition_id: definition_account_id, - balance: transfer_amount - } - ); - - info!("Successfully transferred token using from_label"); - - Ok(()) -} diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index da568ab8..3ada2a73 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::{Stream, StreamExt as _}; use lee::{GENESIS_BLOCK_ID, PublicKey}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, @@ -73,11 +73,11 @@ struct PeerChain { /// /// This, not `max(blocks.keys())`, is what the forgery test gates on: a peer /// picks its own `block_id`s, and an id that does not continue the run - /// cannot advance the run. It bounds how far this reader has read, not that - /// the chain is authentic: the link is self-asserted, since `header.hash` is - /// not recomputed on decode and the reader does not apply the pinned-key - /// check that [`crate::cross_zone_verifier::CrossZoneVerifier::rederive`] - /// applies to the block a dispatch actually names. + /// cannot advance the run. + /// + /// The link it walks means something only because [`accept_peer_block`] + /// recomputes `header.hash` and checks the pinned key before anything is + /// cached. Without that it compared two fields the peer wrote. verified_prefix: Option, } @@ -126,11 +126,38 @@ struct PeerBlocks { } impl PeerBlocks { - async fn insert(&self, zone: ZoneId, block: Block) { + /// Caches `block` unless a different block is already held at its id, and + /// says whether it was newly cached. + /// + /// First write wins. An identical re-read is a no-op, which the reader does + /// on every slot retry. A differing block at a held id is equivocation, and + /// replacing the entry is what makes it a remote halt: the prefix already + /// certified the old value, so the next dispatch naming that id re-derives + /// against the new one and reads as forged. + /// + /// A peer resetting its chain is indistinguishable from this and is refused + /// the same way. The cache is process-local, so a restart adopts it. + async fn insert(&self, zone: ZoneId, block: Block) -> bool { let mut chains = self.chains.write().await; let chain = chains.entry(zone).or_default(); + + if let Some(held) = chain.blocks.get(&block.header.block_id) { + if held.header.hash == block.header.hash { + return false; + } + error!( + "Peer zone {} equivocated at block {}: holding {}, refusing {}. Restart the indexer if this peer legitimately reset its chain.", + hex::encode(zone), + block.header.block_id, + held.header.hash, + block.header.hash + ); + return false; + } + chain.blocks.insert(block.header.block_id, block); chain.extend_prefix(); + true } /// Resolves `block_id` under a single read lock. @@ -220,6 +247,7 @@ impl CrossZoneVerifier { tokio::spawn(read_peer( ZoneIndexer::new(ChannelId::from(peer.channel_id), node), peer.channel_id, + peer_pubkeys.get(&peer.channel_id).cloned(), peers.clone(), config.consensus_info_polling_interval, )); @@ -444,6 +472,43 @@ struct PeerPass { stalled_at: Option, } +/// Whether a block read off a peer's channel may enter the cache. The channel +/// authorizes who may write, not what they may claim. +/// +/// The hash check is unconditional: `header.hash` is a field the peer wrote and +/// the prefix walk compares it against the next block's `prev_block_hash`, so +/// without recomputing it a peer can assert links it never built. The key check +/// applies only when one is pinned, mirroring the watcher; it subsumes the hash +/// check, but a peer with no pinned key still gets that one. +fn accept_peer_block( + block: &Block, + peer_zone: ZoneId, + expected_pubkey: Option<&PublicKey>, +) -> bool { + if block.recompute_hash() != block.header.hash { + warn!( + "Peer reader dropping block {} from {}: header hash {} does not match its contents", + block.header.block_id, + hex::encode(peer_zone), + block.header.hash + ); + return false; + } + + if let Some(expected) = expected_pubkey + && !block.is_signed_by(expected) + { + warn!( + "Peer reader dropping block {} from {}: not signed by the pinned block-signing key", + block.header.block_id, + hex::encode(peer_zone) + ); + return false; + } + + true +} + /// Reads a peer zone's finalized blocks from Bedrock into the shared cache. #[expect( clippy::infinite_loop, @@ -452,6 +517,7 @@ struct PeerPass { async fn read_peer( zone_indexer: ZoneIndexer, peer_zone: ZoneId, + expected_pubkey: Option, peers: PeerBlocks, poll_interval: Duration, ) { @@ -469,7 +535,15 @@ async fn read_peer( loop { match zone_indexer.next_messages(cursor).await { Ok(stream) => { - let pass = consume_peer_stream(stream, peer_zone, &peers, cursor, skip_slot).await; + let pass = consume_peer_stream( + stream, + peer_zone, + expected_pubkey.as_ref(), + &peers, + cursor, + skip_slot, + ) + .await; cursor = pass.cursor; if let Some(slot) = pass.stalled_at { let attempts = match stalled { @@ -521,6 +595,7 @@ async fn read_peer( async fn consume_peer_stream( stream: S, peer_zone: ZoneId, + expected_pubkey: Option<&PublicKey>, peers: &PeerBlocks, resume_from: Option, skip_slot: Option, @@ -543,7 +618,13 @@ where continue; }; match borsh::from_slice::(&zone_block.data) { - Ok(block) => peers.insert(peer_zone, block).await, + Ok(block) => { + // Before caching, not when a dispatch names it: an unchecked + // block steers the prefix, and by then the damage is a halt. + if accept_peer_block(&block, peer_zone, expected_pubkey) { + peers.insert(peer_zone, block).await; + } + } Err(err) if skip_slot == Some(slot) => { debug!( "Peer reader skipping undecodable block from {} at slot {slot:?}: {err}", @@ -835,7 +916,7 @@ mod tests { peer_block_msg(&chain[1], 1), ]); - let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, None).await; + let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None, None).await; assert_eq!(pass.cursor, Some(Slot::from(1))); assert_eq!(pass.stalled_at, None); @@ -891,7 +972,7 @@ mod tests { peer_block_msg(&chain[2], 2), ]); - let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, None).await; + let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None, None).await; assert_eq!(pass.cursor, Some(Slot::from(0))); assert_eq!(pass.stalled_at, Some(Slot::from(1))); @@ -906,7 +987,8 @@ mod tests { // One slot can carry several messages; the second one fails. let stream = stream::iter(vec![peer_block_msg(&chain[0], 7), undecodable_msg(7)]); - let pass = consume_peer_stream(stream, PEER_ZONE, &peers, Some(Slot::from(6)), None).await; + let pass = + consume_peer_stream(stream, PEER_ZONE, None, &peers, Some(Slot::from(6)), None).await; // Slot 7 is re-read whole next pass, not resumed past the failure. assert_eq!(pass.cursor, Some(Slot::from(6))); @@ -924,7 +1006,8 @@ mod tests { peer_block_msg(&chain[2], 2), ]); - let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, Some(Slot::from(1))).await; + let pass = + consume_peer_stream(stream, PEER_ZONE, None, &peers, None, Some(Slot::from(1))).await; assert_eq!(pass.cursor, Some(Slot::from(2)), "the pass drains"); assert_eq!(pass.stalled_at, None); @@ -946,6 +1029,7 @@ mod tests { consume_peer_stream( stream, PEER_ZONE, + None, &verifier.peers, None, Some(Slot::from(1)), @@ -1002,6 +1086,7 @@ mod tests { let pass = consume_peer_stream( stream::iter(vec![undecodable_msg(0)]), PEER_ZONE, + None, &verifier.peers, None, None, @@ -1016,6 +1101,7 @@ mod tests { peer_block_msg(block, u64::try_from(index).expect("test index fits in u64")) })), PEER_ZONE, + None, &verifier.peers, pass.cursor, None, @@ -1029,4 +1115,136 @@ mod tests { .await .expect("the dispatch verifies once the peer block has been read"); } + + /// The live brick from #648: a second, differing block at a used id replaced + /// the entry, so the next dispatch naming that id re-derived against the + /// wrong block and halted ingestion. One inscription, remote halt. + #[tokio::test] + async fn an_equivocating_peer_cannot_replace_a_cached_block() { + let verifier = verifier(); + let real = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]); + let impostor = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"forged")]); + assert_ne!( + real.header.hash, impostor.header.hash, + "the two blocks must differ, or this proves nothing" + ); + + assert!(verifier.peers.insert(PEER_ZONE, real.clone()).await); + assert!( + !verifier.peers.insert(PEER_ZONE, impostor).await, + "a differing block at a held id must be refused" + ); + assert_eq!( + verifier + .peers + .get(PEER_ZONE, PEER_BLOCK_ID) + .await + .unwrap() + .header + .hash, + real.header.hash, + "the block held first stays" + ); + + // And the dispatch that would have been rejected as forged still verifies. + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&block) + .await + .expect("equivocation must not halt ingestion of an honest dispatch"); + } + + /// The reader re-reads a slot on every retry, so caching the same block + /// twice must be a quiet no-op rather than equivocation. + #[tokio::test] + async fn re_reading_the_same_block_is_not_equivocation() { + let verifier = verifier(); + let block = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]); + + assert!(verifier.peers.insert(PEER_ZONE, block.clone()).await); + assert!( + !verifier.peers.insert(PEER_ZONE, block.clone()).await, + "an identical re-read is already held, not newly cached" + ); + assert_eq!( + verifier + .peers + .get(PEER_ZONE, PEER_BLOCK_ID) + .await + .unwrap() + .header + .hash, + block.header.hash + ); + } + + /// Recomputing `header.hash` on the way in is what stops a peer asserting + /// links it never built. + #[tokio::test] + async fn a_block_whose_hash_does_not_match_its_contents_is_not_cached() { + let verifier = verifier(); + let mut tampered = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]); + tampered.header.hash = common::HashType([0xAB; 32]); + + let pass = consume_peer_stream( + stream::iter(vec![peer_block_msg(&tampered, 0)]), + PEER_ZONE, + None, + &verifier.peers, + None, + None, + ) + .await; + + assert_eq!( + pass.stalled_at, None, + "a rejected block is not a decode failure" + ); + assert!( + verifier.peers.get(PEER_ZONE, PEER_BLOCK_ID).await.is_none(), + "a block that does not hash to its own contents must not be cached" + ); + } + + /// The watcher drops unsigned peer blocks before use; the reader applied no + /// check at all, so anything on the channel entered the cache. + #[tokio::test] + async fn a_block_not_signed_by_the_pinned_key_is_not_cached() { + let verifier = verifier(); + let block = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]); + let wrong_key = PublicKey::try_new([42; 32]).unwrap(); + + let pass = consume_peer_stream( + stream::iter(vec![peer_block_msg(&block, 0)]), + PEER_ZONE, + Some(&wrong_key), + &verifier.peers, + None, + None, + ) + .await; + + assert_eq!(pass.stalled_at, None); + assert!( + verifier.peers.get(PEER_ZONE, PEER_BLOCK_ID).await.is_none(), + "a block not signed by the pinned key must not reach the cache" + ); + + // The same block under its real signer is cached, so the gate is the key + // and not the path. + let signer = PublicKey::new_from_private_key(&PrivateKey::try_new([37; 32]).unwrap()); + consume_peer_stream( + stream::iter(vec![peer_block_msg(&block, 1)]), + PEER_ZONE, + Some(&signer), + &verifier.peers, + None, + None, + ) + .await; + assert!( + verifier.peers.get(PEER_ZONE, PEER_BLOCK_ID).await.is_some(), + "the pinned signer's own block is cached" + ); + } } diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index df61b38b..21b96c7f 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -215,6 +215,17 @@ impl SequencerStore { self.dbio.put_zone_anchor(anchor) } + /// The highest block id ever inscribed on the channel by this sequencer, + /// or `None` before it has published anything. + pub fn published_high_water(&self) -> DbResult> { + self.dbio.published_high_water() + } + + /// Raises the published high water mark to `block_id`, never lowering it. + pub fn raise_published_high_water(&self, block_id: u64) -> DbResult<()> { + self.dbio.raise_published_high_water(block_id) + } + pub fn get_pending_deposit_events(&self) -> DbResult> { self.dbio.get_pending_deposit_events() } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index e38a19ca..93de58b0 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -21,7 +21,7 @@ use futures::StreamExt as _; use itertools::Itertools as _; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::GENESIS_BLOCK_ID; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; use logos_blockchain_zone_sdk::{ Slot, ZoneMessage, @@ -262,6 +262,21 @@ impl SequencerCore { .await .expect("Failed to verify/reconstruct sequencer state from Bedrock"); + // Seed the high water mark from the tip we are starting on. Every stored + // block reached the store by being published or by being adopted from + // the channel, so the channel holds them all and none is ours to write + // again. Without this the mark is absent until the first publish of this + // run, leaving that window unguarded — which is exactly the window a + // store written before the mark existed starts in. + if let Some(tip) = store + .latest_block_meta() + .expect("Failed to read latest block meta") + { + store + .raise_published_high_water(tip.id) + .expect("Failed to seed published high water mark"); + } + // 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 @@ -293,6 +308,9 @@ impl SequencerCore { ) }); last_checkpoint = Some(outcome.checkpoint); + store + .raise_published_high_water(block.header.block_id) + .expect("Failed to persist published high water mark"); } // These blocks are already stored, so only the sdk's pending set @@ -322,7 +340,8 @@ impl SequencerCore { /// Verifies the local store still belongs to the chain the connected channel /// serves and replays any finalized channel blocks missing locally into /// `state`/`store`, recording each block's L1 inscription slot as the new - /// anchor. Fails (never parks) on any divergence. + /// anchor. Fails (never parks) when the channel proves a different chain: + /// the anchor consistency check, or a block that will not validate. /// /// 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. @@ -440,8 +459,9 @@ impl SequencerCore { } /// Applies a single channel block during reconstruction: idempotent for - /// blocks we already hold (verifying their hash), a validated continuation - /// for new ones. Advances the persisted anchor to the block's slot. + /// blocks we already hold, ignored when it conflicts at a height the final + /// tier already settled, a validated continuation otherwise. Advances the + /// persisted anchor to the block's slot. fn apply_reconstructed_block( store: &SequencerStore, chain: &mut ChainState, @@ -460,44 +480,44 @@ impl SequencerCore { hash: block_hash, }; - // A block at/below the tip must match what we already stored, otherwise - // the channel is a different chain. + // A block we already hold verbatim needs no replay, but the channel + // serving it is what makes it irreversible, so its deliveries are + // settled and their records are owed nothing. Without this a restart + // leaves a record for every delivery it already published, and nothing + // downstream would ever remove them. if let Some(tip) = &tip && block_id <= tip.id - { - match store + && let Some(stored) = store .get_block_at_id(block_id) .context("Failed to read stored block")? - { - Some(stored) if stored.header.hash == block_hash => { - // Already applied, but the channel serving it is what makes - // it irreversible, so its deliveries are settled and their - // records are owed nothing. Without this a restart leaves a - // record for every delivery it already published, and - // nothing downstream would ever remove them. - settle_reconstructed_deliveries(store, &stored); - store - .set_zone_anchor(&record) - .context("Failed to persist zone anchor")?; - return Ok(()); - } - Some(stored) => { - return Err(anyhow!( - "Channel block {block_id} hash {block_hash} does not match stored hash {}", - stored.header.hash - )); - } - None => { - return Err(anyhow!( - "Channel block {block_id} is at/below local tip {} but is missing locally", - tip.id - )); - } - } + && stored.header.hash == block_hash + { + settle_reconstructed_deliveries(store, &stored); + store + .set_zone_anchor(&record) + .context("Failed to persist zone anchor")?; + return Ok(()); } - // New continuation: channel history is finalized, so it goes through - // the final tier — validation happens inside `apply_finalized`. + // A conflict at a height the final tier already settled: the channel + // carries two inscriptions for one block id — competing sequencers + // around a turn change — and finality already picked one, so the other + // is dropped. `apply_adopted` ignores the same conflict. A genuinely + // foreign channel is caught upstream by the anchor consistency check, + // not here; the anchor stays on the block we hold. + if let Some(final_tip) = chain.final_tip() + && block_id <= final_tip.block_id + { + log::warn!( + "Ignoring channel block {block_id} with hash {block_hash} conflicting with the \ + finalized block at this height" + ); + return Ok(()); + } + + // Above the final tier the head is reorg-able, so finalized history + // wins: `apply_finalized` finalizes the matching prefix and rebases the + // head onto what the channel settled. Validation happens inside it. match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) { AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {} AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => { @@ -569,6 +589,12 @@ impl SequencerCore { .await .context("Failed to publish block to Bedrock")?; + // The inscription is on L1 from here on, whatever the head does with the + // block below, so this height must never be published again. + self.store + .raise_published_high_water(block.header.block_id) + .context("Failed to persist published high water mark")?; + let withdrawal_reconciliation_keys: Vec<_> = released_notes .iter() .map(withdrawal_reconciliation_key) @@ -1106,6 +1132,37 @@ impl SequencerCore { self.block_publisher.is_our_turn() } + /// The height the next produced block would claim. + #[must_use] + pub fn next_block_height(&self) -> u64 { + self.chain + .lock() + .expect("chain state mutex poisoned") + .head_tip() + .map_or(GENESIS_BLOCK_ID, |tip| { + tip.block_id + .checked_add(1) + .expect("block id should not overflow") + }) + } + + /// `Some(high_water)` when the head has rewound below what we already + /// inscribed, so the next block would be a *second*, different block at a + /// height the channel already carries. Callers must skip their turn. + /// + /// The head alone cannot detect this: an orphan report for our own + /// still-unfinalized blocks rewinds it (`ChainState::apply_channel_update`) + /// and prunes those blocks from the store, so the tip reads as if they were + /// never produced. The mark is kept outside that pruning for exactly this. + /// + /// This is not a stall — the head recovers by itself once the inscriptions + /// we are protecting finalize and the final tier rebases onto them. + #[must_use] + pub fn rewound_below_published(&self) -> Option { + let high_water = self.store.published_high_water().ok().flatten()?; + (self.next_block_height() <= high_water).then_some(high_water) + } + /// Shared handle to the two-tier follow state, for tests to drive the /// follow path directly. #[cfg(all(test, feature = "mock"))] @@ -1201,8 +1258,48 @@ fn apply_follow_update( let (resubmit_txs, outcome, head_height) = { let mut chain = chain.lock().expect("chain state mutex poisoned"); + // An orphan report rewinds the head to the earliest orphaned block and + // prunes the store above it, which is how a run of our own inscriptions + // can silently stop being ours. Loud on the way in: it is the only + // trace, and the rewind it causes is the expensive one. + // Debug, not warn: the sdk orphans our blocks routinely once LIB pruning + // drops them from the lineage, and most of those no longer sit in the + // head. The rewind below is the part that costs something. + let head_before = chain.head_tip().map(|tip| tip.block_id); + if !orphaned.is_empty() { + let ids: Vec = orphaned + .iter() + .map(|(_, block)| block.header.block_id) + .collect(); + debug!( + "Channel orphaned {} block(s) {:?}..={:?}, head tip is {head_before:?}", + ids.len(), + ids.iter().min(), + ids.iter().max(), + ); + } + // Outcomes align with `adopted`. let outcomes = chain.apply_channel_update(&orphaned, &adopted); + + // An adoption that does not apply freezes the head where it is, and + // every later one then fails the same way. Nothing else reports it. + for ((_, block), outcome) in adopted.iter().zip(&outcomes) { + if let AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) = outcome { + warn!( + "Adopted block {} did not apply, head stays at {:?}: {err}", + block.header.block_id, + chain.head_tip().map(|tip| tip.block_id), + ); + } + } + + if let (Some(before), Some(after)) = (head_before, chain.head_tip().map(|tip| tip.block_id)) + && after < before + { + warn!("Head rewound from {before} to {after}"); + } + let mut to_persist: Vec<(&Block, bool)> = adopted .iter() .zip(&outcomes) diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 8d3f787b..ae0a6c31 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -2011,6 +2011,79 @@ async fn follow_update_persists_the_checkpoint_with_its_effects() { assert!(sequencer.store.get_block_at_id(2).unwrap().is_some()); } +/// The channel orphaning our own still-unfinalized blocks rewinds the head and +/// prunes them from the store, so nothing in the chain state remembers we ever +/// produced them. Producing again there would put a second, different block at +/// a height the channel already carries — the fork that has to be prevented. +#[tokio::test] +async fn head_rewound_below_published_height_blocks_production() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let first = sequencer.produce_new_block().await.unwrap(); + let published_tip = sequencer.produce_new_block().await.unwrap(); + assert_eq!( + sequencer.store.published_high_water().unwrap(), + Some(published_tip), + "publishing records the high water mark" + ); + assert!( + sequencer.rewound_below_published().is_none(), + "an intact head is free to produce" + ); + + let produced: Vec = [first, published_tip] + .into_iter() + .map(|id| sequencer.store.get_block_at_id(id).unwrap().unwrap()) + .collect(); + + // The sdk reports both of them as orphaned. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + orphaned: produced + .iter() + .map(|block| (MsgId::from([0_u8; 32]), block.clone())) + .collect(), + ..empty_follow_update() + }, + ); + + assert!( + sequencer.store.latest_block_meta().unwrap().unwrap().id < published_tip, + "the orphan report rewound the stored tip" + ); + assert_eq!( + sequencer.rewound_below_published(), + Some(published_tip), + "the mark outlives the pruning and blocks the turn" + ); + + // Those inscriptions were on the channel all along: finalizing them rebases + // the head onto them, and production is free again. The guard is a wait. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + finalized: produced + .iter() + .map(|block| (MsgId::from([1_u8; 32]), block.clone())) + .collect(), + ..empty_follow_update() + }, + ); + + assert_eq!(sequencer.next_block_height(), published_tip + 1); + assert!( + sequencer.rewound_below_published().is_none(), + "a recovered head resumes producing" + ); +} + #[tokio::test] async fn follow_update_records_deposits_for_the_production_drain() { let config = setup_sequencer_config(); diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index 846abe20..6365fca8 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -190,15 +190,17 @@ async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() { } #[tokio::test] -async fn fails_when_a_stored_block_hash_diverges_from_the_channel() { +async fn fails_when_a_below_tip_channel_block_does_not_validate() { // A sequencer that committed blocks past genesis but never recorded an anchor. let config = setup_sequencer_config(); let (mut seq, _handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; seq.produce_new_block().await.unwrap(); seq.produce_new_block().await.unwrap(); - // A below-tip block re-served with a corrupted hash: we already hold this id - // with a different hash, so the channel is a different chain. + // A below-tip block re-served with a corrupted hash. Holding a different + // block at that id is not itself grounds to abort — the head tier is + // reorg-able — but this one's header hash does not cover its contents, so it + // parks on validation. let below_tip_id = seq.block_store().genesis_id() + 1; let mut block = seq .block_store() @@ -219,17 +221,18 @@ async fn fails_when_a_stored_block_hash_diverges_from_the_channel() { .await; assert!( result.is_err(), - "a diverging below-tip block hash must abort startup" + "an unverifiable below-tip block must abort startup" ); } #[tokio::test] -async fn fails_when_a_channel_block_is_missing_locally() { +async fn fails_when_a_channel_block_is_numbered_below_genesis() { let config = setup_sequencer_config(); let (store, chain) = fresh_store_and_chain(&config); - // A block numbered below our genesis is at/below the local tip yet absent from - // the store — a foreign chain with a lower numbering. + // A block numbered below our genesis — a foreign chain with a lower + // numbering. Nothing local sits at that id, so it goes straight to + // validation and parks there. let mut foreign = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); foreign.header.block_id = store.genesis_id() - 1; @@ -272,6 +275,152 @@ async fn fails_when_a_channel_block_does_not_extend_the_tip() { ); } +// The two cases below reproduce the real startup order: zone-sdk's cold-start +// backfill runs inside `BP::new` and populates the store *before* +// `verify_and_reconstruct`, so reconstruction re-reads history it already holds. +// A conflict there is a competing sequencer, not a foreign chain. + +/// The channel carries two inscriptions for one block id — competing sequencers +/// around a turn change — and the final tier already settled that height. +/// Finality is irreversible, so the loser is ignored rather than fatal. +#[tokio::test] +async fn reconstruction_ignores_a_duplicate_height_the_final_tier_settled() { + // Sequencer A's chain is what the channel finalized. + let config_a = setup_sequencer_config(); + let (mut seq_a, _mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + seq_a.produce_new_block().await.unwrap(); + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); + let mut messages = channel_from_store(seq_a.block_store(), 10); + let settled_slot = messages.last().unwrap().1; + + // Sequencer B: the cold-start backfill finalizes A's chain into its store. + let (seq_b, mempool_b) = + SequencerCoreWithMockClients::start_from_config(setup_sequencer_config()).await; + let finalized: Vec<(MsgId, Block)> = (seq_b.block_store().genesis_id()..=tip_a.id) + .map(|id| { + let block = seq_a.block_store().get_block_at_id(id).unwrap().unwrap(); + ( + MsgId::from([u8::try_from(id).expect("should be u8"); 32]), + block, + ) + }) + .collect(); + apply_follow_update( + &seq_b.store.dbio(), + &seq_b.chain(), + &mempool_b, + FollowUpdate { + finalized, + ..empty_follow_update() + }, + ); + assert_eq!( + seq_b + .chain() + .lock() + .unwrap() + .final_tip() + .expect("backfill finalized A's chain") + .block_id, + tip_a.id, + ); + + // A competitor published its own block at that same height. + let parent = seq_a + .block_store() + .get_block_at_id(tip_a.id - 1) + .unwrap() + .unwrap(); + let competitor = + common::test_utils::produce_dummy_block(tip_a.id, Some(parent.header.hash), vec![]); + assert_ne!(competitor.header.hash, tip_a.hash); + messages.push(block_to_channel_message(&competitor, 999)); + + let mock_b = MockBlockPublisher::with_canned_channel( + config_a.bedrock_config.channel_id, + Some(Slot::from(999)), + messages, + ); + SequencerCore::::verify_and_reconstruct( + &mock_b, + &seq_b.store, + &seq_b.chain, + true, + ) + .await + .expect("a duplicate height the final tier settled must not abort startup"); + + let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); + assert_eq!(tip_b.hash, tip_a.hash, "the finalized block stands"); + + // The anchor tracks the block we hold, never the one we dropped. + let anchor = seq_b + .block_store() + .get_zone_anchor() + .unwrap() + .expect("anchor"); + assert_eq!(anchor.slot, settled_slot.into_inner()); + assert_eq!(anchor.hash, tip_a.hash); +} + +/// A block the head tier holds is reorg-able by construction, so finalized +/// channel history at that height wins and the head rebases onto it. +#[tokio::test] +async fn reconstruction_replaces_a_conflicting_head_block_with_finalized_history() { + // Sequencer A's chain is what the channel finalized. + let config_a = setup_sequencer_config(); + let (mut seq_a, _mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + seq_a.produce_new_block().await.unwrap(); + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + + // Sequencer B adopted a competitor at that height and never saw it finalize. + let (seq_b, mempool_b) = + SequencerCoreWithMockClients::start_from_config(setup_sequencer_config()).await; + let genesis_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); + let competitor = + common::test_utils::produce_dummy_block(tip_a.id, Some(genesis_b.hash), vec![]); + assert_ne!(competitor.header.hash, tip_a.hash); + apply_follow_update( + &seq_b.store.dbio(), + &seq_b.chain(), + &mempool_b, + FollowUpdate { + adopted: vec![(MsgId::from([7_u8; 32]), competitor)], + ..empty_follow_update() + }, + ); + assert_eq!( + seq_b.block_store().latest_block_meta().unwrap().unwrap().id, + tip_a.id, + "the competitor is the head tip going in" + ); + + let mock_b = MockBlockPublisher::with_canned_channel( + config_a.bedrock_config.channel_id, + Some(tip_slot), + messages, + ); + SequencerCore::::verify_and_reconstruct( + &mock_b, + &seq_b.store, + &seq_b.chain, + true, + ) + .await + .expect("finalized history must replace a conflicting head block"); + + let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); + assert_eq!(tip_b.id, tip_a.id); + assert_eq!( + tip_b.hash, tip_a.hash, + "the finalized block replaces the head competitor" + ); +} + /// A sequencer config whose genesis funds the bridge account, so replayed bridge /// deposit transactions have a source balance to mint from. fn bridge_funded_config() -> SequencerConfig { diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index 3073823a..683b8ad4 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -5,7 +5,7 @@ use bytesize::ByteSize; use common::transaction::LeeTransaction; use futures::never::Never; use jsonrpsee::server::ServerHandle; -use log::{error, info}; +use log::{error, info, warn}; use mempool::MemPoolHandle; #[cfg(not(feature = "standalone"))] use sequencer_core::SequencerCore; @@ -294,6 +294,19 @@ async fn main_loop(seq_core: Arc>, block_timeout: Duration) continue; } + // Never inscribe a second block at a height we already published: the + // channel would carry two chains from there and nothing resolves that. + // The head rewinds under us when the sdk orphans our own unfinalized + // blocks, and recovers once they finalize, so this is a wait. + if let Some(high_water) = state.rewound_below_published() { + warn!( + "Skipping turn: head rewound to {} but block {high_water} is already inscribed; \ + waiting for the channel to restore it", + state.next_block_height().saturating_sub(1), + ); + continue; + } + info!("Our turn: collecting transactions from mempool, creating block"); let id = state.produce_new_block().await?; info!("Block with id {id} created"); diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index ba6deaf9..ebb31e18 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -25,9 +25,9 @@ use crate::{ LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PeerFloorCellOwned, PeerFloorCellRef, PeerZoneKey, PendingCrossZoneDispatchRecord, PendingCrossZoneDispatchesCellOwned, PendingCrossZoneDispatchesCellRef, PendingDepositEventRecord, - PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell, - WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, - ZoneSdkCheckpointCellRef, + PendingDepositEventsCellOwned, PendingDepositEventsCellRef, PublishedHighWaterCell, + UnseenWithdrawCountCell, WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, + ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef, }, }; @@ -56,6 +56,11 @@ pub const DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY: &str = "pending_cross_zone_ /// Key base for counting unseen L2 withdraw intents. pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count"; +/// Key base for the highest block id this sequencer has ever inscribed on the +/// channel. Never decreases, and deliberately survives the block pruning a +/// head rewind performs. +pub const DB_META_PUBLISHED_HIGH_WATER_KEY: &str = "published_high_water"; + /// How many cross-zone deliveries may be pending at once. /// /// The whole list is a single value, read on every block and rewritten on every @@ -491,6 +496,25 @@ impl RocksDBIO { self.del::(()) } + /// The highest block id this sequencer has ever inscribed, or `None` if it + /// has never published. Read fresh: a head rewind prunes blocks, so the + /// stored tip is not a safe substitute. + pub fn published_high_water(&self) -> DbResult> { + self.get_opt::(()) + .map(|val| val.map(|cell| cell.0)) + } + + /// Raises the published high water mark to `block_id`, never lowering it. + pub fn raise_published_high_water(&self, block_id: u64) -> DbResult<()> { + if self + .published_high_water()? + .is_some_and(|mark| mark >= block_id) + { + return Ok(()); + } + self.put(&PublishedHighWaterCell(block_id), ()) + } + pub fn get_zone_anchor(&self) -> DbResult> { Ok(self.get_opt::(())?.map(|cell| cell.0)) } diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 521fff5e..d7da254b 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -10,8 +10,9 @@ use crate::{ CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY, DB_META_CROSS_ZONE_PEER_FLOOR_KEY, DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, - DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY, - DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, + DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_PUBLISHED_HIGH_WATER_KEY, + DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_CURSOR_KEY, + DB_META_ZONE_SDK_CHECKPOINT_KEY, }, }; @@ -134,6 +135,30 @@ impl SimpleWritableCell for LastFinalizedBlockIdCell { } } +/// The highest block id ever inscribed on the channel by this sequencer. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct PublishedHighWaterCell(pub u64); + +impl SimpleStorableCell for PublishedHighWaterCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_PUBLISHED_HIGH_WATER_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for PublishedHighWaterCell {} + +impl SimpleWritableCell for PublishedHighWaterCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize published high water mark".to_owned()), + ) + }) + } +} + #[derive(BorshDeserialize)] pub struct LatestBlockMetaCellOwned(pub BlockMeta);