mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-08-07 15:33:13 +00:00
Merge pull request #688 from logos-blockchain/dev
chore: dev to main for 0.2.3
This commit is contained in:
commit
43b66b1592
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
@ -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<u64>,
|
||||
}
|
||||
|
||||
@ -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<Slot>,
|
||||
}
|
||||
|
||||
/// 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<NodeHttpClient>,
|
||||
peer_zone: ZoneId,
|
||||
expected_pubkey: Option<PublicKey>,
|
||||
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<S>(
|
||||
stream: S,
|
||||
peer_zone: ZoneId,
|
||||
expected_pubkey: Option<&PublicKey>,
|
||||
peers: &PeerBlocks,
|
||||
resume_from: Option<Slot>,
|
||||
skip_slot: Option<Slot>,
|
||||
@ -543,7 +618,13 @@ where
|
||||
continue;
|
||||
};
|
||||
match borsh::from_slice::<Block>(&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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Option<u64>> {
|
||||
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<Vec<PendingDepositEventRecord>> {
|
||||
self.dbio.get_pending_deposit_events()
|
||||
}
|
||||
|
||||
@ -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<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
.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<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
)
|
||||
});
|
||||
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<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
/// 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<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
}
|
||||
|
||||
/// 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<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
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<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
.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<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
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<u64> {
|
||||
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<u64> = 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)
|
||||
|
||||
@ -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<Block> = [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();
|
||||
|
||||
@ -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::<MockBlockPublisher>::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::<MockBlockPublisher>::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 {
|
||||
|
||||
@ -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<Mutex<SequencerCore>>, 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");
|
||||
|
||||
@ -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::<ZoneSdkCheckpointCellOwned>(())
|
||||
}
|
||||
|
||||
/// 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<Option<u64>> {
|
||||
self.get_opt::<PublishedHighWaterCell>(())
|
||||
.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<Option<ZoneAnchorRecord>> {
|
||||
Ok(self.get_opt::<ZoneAnchorCell>(())?.map(|cell| cell.0))
|
||||
}
|
||||
|
||||
@ -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<Vec<u8>> {
|
||||
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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user