Merge branch 'dev' into Pravdyvy/multi-sequencer-integration-tests

This commit is contained in:
Pravdyvy
2026-07-28 14:24:15 +03:00
78 changed files with 1191 additions and 1247 deletions
+1
View File
@@ -299,6 +299,7 @@ jobs:
valid-proof-test:
needs: ci-image
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
Generated
+2 -1
View File
@@ -1952,6 +1952,8 @@ dependencies = [
"ping_core",
"programs",
"risc0-zkvm",
"serde",
"wrapped_token_core",
]
[[package]]
@@ -10282,7 +10284,6 @@ dependencies = [
"programs",
"serde",
"system_accounts",
"wrapped_token_core",
]
[[package]]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+50
View File
@@ -12,6 +12,7 @@ use log::info;
use sequencer_service_rpc::RpcClient as _;
pub use test_fixtures::*;
use wallet::{
AccountIdentity,
cli::{
CliAccountMention, Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
@@ -19,6 +20,7 @@ use wallet::{
native_token_transfer::AuthTransferSubcommand, token::TokenProgramAgnosticSubcommand,
},
},
program_facades::{native_token_transfer::NativeTokenTransfer, token::Token},
storage::key_chain::FoundPrivateAccount,
};
@@ -68,6 +70,34 @@ pub async fn send(
Ok(())
}
/// Like [`send`], but for a `to` that is still a fresh, unclaimed account.
///
/// The wallet CLI's `AuthTransfer::Send` never signs with the recipient's key (by design: the
/// sender's wallet must not sign on behalf of an account it doesn't own). But claiming a fresh
/// account is only possible if that account's own key signs the transaction, so this bypasses
/// the CLI and calls the program facade directly with an explicit `AccountIdentity::Public` for
/// the recipient, using the key the test wallet holds for the account it just created.
///
/// Unlike `send`, this doesn't go through the CLI's own poll-until-included step, so it waits
/// for block creation itself before returning.
pub async fn send_claiming_new_account(
ctx: &mut TestContext,
from: AccountId,
to: AccountId,
amount: u128,
) -> Result<()> {
NativeTokenTransfer(ctx.wallet())
.send_public_transfer(
AccountIdentity::Public(from),
AccountIdentity::Public(to),
amount,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
Ok(())
}
/// Create a token (New) and wait for the block to be included.
pub async fn create_token(
ctx: &mut TestContext,
@@ -110,6 +140,26 @@ pub async fn token_send(
Ok(())
}
/// Like [`token_send`], but for a `to` that is still a fresh, unclaimed holding account. See
/// [`send_claiming_new_account`] for why the CLI can't be used here.
pub async fn token_send_claiming_new_account(
ctx: &mut TestContext,
from: AccountId,
to: AccountId,
amount: u128,
) -> Result<()> {
Token(ctx.wallet())
.send_transfer_transaction(
AccountIdentity::Public(from),
AccountIdentity::Public(to),
amount,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
Ok(())
}
/// Retrieve the native token balance for `account_id`.
pub async fn account_balance(ctx: &TestContext, account_id: AccountId) -> Result<u128> {
Ok(ctx
+15 -31
View File
@@ -9,7 +9,7 @@ use std::time::Duration;
use anyhow::Result;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, create_token, get_account, new_account,
public_mention, token_send,
public_mention, token_send_claiming_new_account,
};
use log::info;
use tokio::test;
@@ -54,14 +54,11 @@ async fn amm_public() -> Result<()> {
)
.await?;
// Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_1`
token_send(
&mut ctx,
public_mention(supply_account_id_1),
public_mention(recipient_account_id_1),
7,
)
.await?;
// Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_1`.
// `recipient_account_id_1` is still unclaimed, so this bypasses the wallet CLI (which never
// signs with the recipient's key) and signs with the recipient's own key directly.
token_send_claiming_new_account(&mut ctx, supply_account_id_1, recipient_account_id_1, 7)
.await?;
// Create new token
create_token(
@@ -73,14 +70,10 @@ async fn amm_public() -> Result<()> {
)
.await?;
// Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_2`
token_send(
&mut ctx,
public_mention(supply_account_id_2),
public_mention(recipient_account_id_2),
7,
)
.await?;
// Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_2`.
// `recipient_account_id_2` is still unclaimed, so this bypasses the wallet CLI the same way.
token_send_claiming_new_account(&mut ctx, supply_account_id_2, recipient_account_id_2, 7)
.await?;
info!("=================== SETUP FINISHED ===============");
@@ -351,13 +344,9 @@ async fn amm_new_pool_using_labels() -> Result<()> {
)
.await?;
token_send(
&mut ctx,
public_mention(supply_account_id_1),
public_mention(holding_a_id),
5,
)
.await?;
// `holding_a_id` is still unclaimed, so bypass the wallet CLI (see
// `token_send_claiming_new_account`'s docs for why).
token_send_claiming_new_account(&mut ctx, supply_account_id_1, holding_a_id, 5).await?;
// Create token 2 and distribute to holding_b
create_token(
@@ -369,13 +358,8 @@ async fn amm_new_pool_using_labels() -> Result<()> {
)
.await?;
token_send(
&mut ctx,
public_mention(supply_account_id_2),
public_mention(holding_b_id),
5,
)
.await?;
// `holding_b_id` is still unclaimed, so bypass the wallet CLI the same way.
token_send_claiming_new_account(&mut ctx, supply_account_id_2, holding_b_id, 5).await?;
// Create AMM pool using account labels instead of IDs
let subcommand = AmmProgramAgnosticSubcommand::New {
@@ -83,7 +83,6 @@ async fn private_transfer_to_foreign_account() -> Result<()> {
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;
assert_eq!(tx.message.new_commitments[0], new_commitment1);
assert_eq!(tx.message.new_commitments.len(), 2);
for commitment in tx.message.new_commitments {
assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await);
}
@@ -128,6 +127,47 @@ async fn deshielded_transfer_to_public_account() -> Result<()> {
Ok(())
}
/// A deshielded transfer's public recipient must not be asked to sign the transaction: the
/// sender's private-side proof is the only authorization the protocol requires, and signing
/// with the recipient's key (when the wallet happens to hold it) would leak a link between
/// the two accounts.
#[test]
async fn deshielded_transfer_does_not_sign_with_recipient_key() -> Result<()> {
let mut ctx = TestContext::new().await?;
let from: AccountId = ctx.existing_private_accounts()[0];
let to: AccountId = ctx.existing_public_accounts()[1];
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(from),
to: Some(public_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;
assert!(
tx.witness_set().signatures_and_public_keys().is_empty(),
"deshielded transfer must not carry any signature, in particular not the recipient's"
);
info!("Deshielded transfer correctly did not sign with the recipient's key");
Ok(())
}
#[test]
async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
let mut ctx = TestContext::new().await?;
@@ -172,7 +212,6 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
.context("Failed to get private account commitment for sender")?;
assert_eq!(tx.message.new_commitments[0], sender_commitment);
assert_eq!(tx.message.new_commitments.len(), 2);
for commitment in tx.message.new_commitments {
assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await);
}
@@ -303,7 +342,6 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Verify commitments are in state
assert_eq!(tx.message.new_commitments.len(), 2);
for commitment in tx.message.new_commitments {
assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await);
}
+49 -21
View File
@@ -1,19 +1,19 @@
use std::time::Duration;
use anyhow::Result;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, new_account,
public_mention, send,
public_mention, send, send_claiming_new_account,
};
use lee::public_transaction;
use lee::{PublicKey, public_transaction};
use log::info;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::{
account::Label,
cli::{
CliAccountMention, Command, account::AccountSubcommand,
CliAccountMention, Command, SubcommandReturnValue, account::AccountSubcommand,
programs::native_token_transfer::AuthTransferSubcommand,
},
};
@@ -24,13 +24,20 @@ async fn successful_transfer_to_existing_account() -> Result<()> {
let sender = ctx.existing_public_accounts()[0];
let receiver = ctx.existing_public_accounts()[1];
send(
&mut ctx,
public_mention(sender),
public_mention(receiver),
100,
)
.await?;
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(sender),
to: Some(public_mention(receiver)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
@@ -45,6 +52,34 @@ async fn successful_transfer_to_existing_account() -> Result<()> {
assert_eq!(acc_1_balance, 9900);
assert_eq!(acc_2_balance, 20100);
// The recipient already exists, so the protocol doesn't require its signature, and the
// wallet must never sign with a key it doesn't need to use. Assert the transfer's witness
// set contains exactly the sender's signature, not the recipient's.
let (tx, _block_id) = ctx
.sequencer_client()
.get_transaction(tx_hash)
.await?
.context("transfer transaction should be included in a block")?;
let LeeTransaction::Public(tx) = tx else {
anyhow::bail!("Expected a public transaction");
};
let sender_public_key = PublicKey::new_from_private_key(
ctx.wallet()
.get_account_public_signing_key(sender)
.context("sender should have a signing key")?,
);
let signers: Vec<_> = tx
.witness_set()
.signatures_and_public_keys()
.iter()
.map(|(_, public_key)| public_key)
.collect();
assert_eq!(
signers,
vec![&sender_public_key],
"only the sender should sign a transfer to an existing account"
);
Ok(())
}
@@ -55,16 +90,9 @@ pub async fn successful_transfer_to_new_account() -> Result<()> {
let new_persistent_account_id = new_account(&mut ctx, false, None).await?;
let sender = ctx.existing_public_accounts()[0];
send(
&mut ctx,
public_mention(sender),
public_mention(new_persistent_account_id),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// The wallet CLI never signs with the recipient's key, but claiming this fresh account
// requires it, so bypass the CLI for this one send.
send_claiming_new_account(&mut ctx, sender, new_persistent_account_id, 100).await?;
info!("Checking correct balance move");
let acc_1_balance = account_balance(&ctx, sender).await?;
+9 -6
View File
@@ -8,6 +8,13 @@
//! zone B, where the indexer re-derives and verifies it (Option B) before the
//! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged;
//! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new.
//!
//! Not production-safe. The inbox allowlist gates the target program, not the
//! source emitter, and `extract_emission` recognizes any known emitter, so in a
//! zone that allows `wrapped_token` as a target a permissionless `ping_sender`
//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe
//! needs source verification, where a value-bearing target checks the message
//! originated from `bridge_lock`; that is out of scope for the demo.
use std::time::Duration;
@@ -103,16 +110,12 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
// escrow now.
let seq_a_client = sequencer_client(seq_a.addr())?;
let escrow_id = bridge_lock_core::escrow_account_id(programs::bridge_lock().id());
let escrowed = bridge_lock_core::read_balance(
&seq_a_client.get_account(escrow_id).await?.data.into_inner(),
);
let escrowed = seq_a_client.get_account(escrow_id).await?.balance;
assert_eq!(
escrowed, LOCK_AMOUNT,
"zone A escrow must hold the locked amount"
);
let remaining = bridge_lock_core::read_balance(
&seq_a_client.get_account(holder_id).await?.data.into_inner(),
);
let remaining = seq_a_client.get_account(holder_id).await?.balance;
assert_eq!(
remaining,
INITIAL_BALANCE - LOCK_AMOUNT,
@@ -54,7 +54,7 @@ fn seed_inbox_config(
allowed_peers: BTreeMap::new(),
allowed_targets,
};
state.insert_genesis_account(
*state = std::mem::replace(state, V03State::new()).with_public_accounts([(
inbox_config_account_id(inbox_id),
Account {
program_owner: inbox_id,
@@ -65,14 +65,14 @@ fn seed_inbox_config(
.expect("config fits in account data"),
nonce: 0_u128.into(),
},
);
)]);
}
/// Seeds the wrapped-token config account pinning the inbox as authorized minter,
/// matching what genesis seeds for a real zone.
fn seed_wrapped_config(state: &mut V03State) {
let wrapped_token_id = programs::wrapped_token().id();
state.insert_genesis_account(
*state = std::mem::replace(state, V03State::new()).with_public_accounts([(
wrapped_token_core::config_account_id(wrapped_token_id),
Account {
program_owner: wrapped_token_id,
@@ -82,7 +82,7 @@ fn seed_wrapped_config(state: &mut V03State) {
.expect("minter id fits in account data"),
..Default::default()
},
);
)]);
}
/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone
@@ -170,18 +170,14 @@ fn lock_escrows_balance_and_emits_to_outbox() {
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
state.insert_genesis_account(
state = state.with_public_accounts([(
holder_id,
Account {
program_owner: bridge_lock_id,
balance: 0,
data: bridge_lock_core::balance_bytes(INITIAL_BALANCE)
.to_vec()
.try_into()
.expect("balance fits in account data"),
nonce: 0_u128.into(),
balance: INITIAL_BALANCE,
..Default::default()
},
);
)]);
let payload = mint_payload();
let target_accounts = vec![
@@ -214,16 +210,14 @@ fn lock_escrows_balance_and_emits_to_outbox() {
.expect("lock must validate and execute");
let public_diff = diff.public_diff();
let holder_after =
bridge_lock_core::read_balance(&public_diff[&holder_id].data.clone().into_inner());
let holder_after = public_diff[&holder_id].balance;
assert_eq!(
holder_after,
INITIAL_BALANCE - LOCK_AMOUNT,
"holder debited"
);
let escrow_after =
bridge_lock_core::read_balance(&public_diff[&escrow_id].data.clone().into_inner());
let escrow_after = public_diff[&escrow_id].balance;
assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited");
let record =
@@ -314,7 +308,7 @@ fn mint_replay_rejected() {
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
let mut shard = SeenShard::default();
shard.insert(message_key(&src_zone, src_block_id, src_tx_index));
state.insert_genesis_account(
state = state.with_public_accounts([(
seen_id,
Account {
program_owner: inbox_id,
@@ -325,7 +319,7 @@ fn mint_replay_rejected() {
.expect("shard fits in account data"),
nonce: 0_u128.into(),
},
);
)]);
let msg = CrossZoneMessage {
src_zone,
+5 -17
View File
@@ -10,7 +10,7 @@ use anyhow::{Context as _, Result};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, assert_public_account_restored,
fetch_privacy_preserving_tx, new_account, private_mention, public_mention,
restored_private_account, send, verify_commitment_is_in_state,
restored_private_account, send, send_claiming_new_account, verify_commitment_is_in_state,
};
use key_protocol::key_management::key_tree::chain_index::ChainIndex;
use lee::AccountId;
@@ -73,7 +73,6 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> {
.context("Failed to get private account commitment for sender")?;
assert_eq!(tx.message.new_commitments[0], new_commitment1);
assert_eq!(tx.message.new_commitments.len(), 2);
for commitment in tx.message.new_commitments {
assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await);
}
@@ -121,21 +120,10 @@ async fn restore_keys_from_seed() -> Result<()> {
let to_account_id3 = new_account(&mut ctx, false, Some(ChainIndex::root())).await?;
let to_account_id4 = new_account(&mut ctx, false, Some(ChainIndex::from_str("/0")?)).await?;
// Send to both public accounts
send(
&mut ctx,
public_mention(from),
public_mention(to_account_id3),
102,
)
.await?;
send(
&mut ctx,
public_mention(from),
public_mention(to_account_id4),
103,
)
.await?;
// Send to both public accounts. Both are still unclaimed, so bypass the wallet CLI (which
// never signs with the recipient's key) and sign with the recipient's own key directly.
send_claiming_new_account(&mut ctx, from, to_account_id3, 102).await?;
send_claiming_new_account(&mut ctx, from, to_account_id4, 103).await?;
info!("Preparation complete, performing keys restoration");
@@ -0,0 +1,34 @@
#![expect(
clippy::tests_outside_test_module,
reason = "We don't care about these in tests"
)]
use anyhow::Result;
use integration_tests::{TestContext, fetch_privacy_preserving_tx, new_account, private_mention};
use tokio::test;
use wallet::cli::{
Command, SubcommandReturnValue, programs::native_token_transfer::AuthTransferSubcommand,
};
#[test]
async fn private_transaction_pads_notes_to_max() -> Result<()> {
let mut ctx = TestContext::new().await?;
let account_id = new_account(&mut ctx, true, None).await?;
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
account_id: private_mention(account_id),
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;
assert_eq!(tx.message.new_commitments.len(), 7);
assert_eq!(tx.message.new_nullifiers.len(), 7);
assert_eq!(tx.message.encrypted_private_post_states.len(), 7);
Ok(())
}
@@ -12,7 +12,6 @@ use std::{path::Path, time::Duration};
use anyhow::{Context as _, Result, bail};
use indexer_service_rpc::RpcClient as _;
use integration_tests::L2_TO_L1_TIMEOUT;
use lee::{AccountId, PrivateKey, PublicKey};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use sequencer_core::config::GenesisAction;
@@ -24,6 +23,10 @@ use test_fixtures::{
};
use tokio::test;
/// Finalization can lag several minutes under CI load; give the bootstrap and
/// reconstruction waits generous headroom so runner-speed variance does not flake.
const FINALIZE_TIMEOUT: Duration = Duration::from_mins(12);
/// Block cadence for the tests: short so we don't wait long for local production.
fn fast_blocks() -> SequencerPartialConfig {
SequencerPartialConfig {
@@ -227,7 +230,7 @@ async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> {
// Wait until those blocks are finalized on Bedrock — reconstruction only
// reads finalized history. A stays alive so its publish task keeps flushing.
let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, L2_TO_L1_TIMEOUT).await?;
let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, FINALIZE_TIMEOUT).await?;
// Stop A, then wipe just its L2 store (keeping the bedrock signing key) so it
// restarts from an empty store on the same channel/identity — a sequencer that
@@ -398,7 +401,7 @@ async fn local_ahead_of_channel_resumes() -> Result<()> {
.await
.context("Failed to start sequencer A")?;
let client_a = sequencer_client(handle_a.addr())?;
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?;
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZE_TIMEOUT).await?;
let tip_before = client_a.get_last_block_id().await?;
assert!(
tip_before > finalized,
@@ -501,7 +504,7 @@ async fn local_behind_channel_reconstructs_forward() -> Result<()> {
.setup_at(home.path())
.await
.context("Failed to resume sequencer")?;
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?;
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZE_TIMEOUT).await?;
drop(handle);
tokio::time::sleep(Duration::from_secs(2)).await;
finalized
+32 -30
View File
@@ -9,7 +9,7 @@ use std::time::Duration;
use anyhow::{Context as _, Result};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account, new_account, private_mention,
public_mention, sync_private, verify_commitment_is_in_state,
public_mention, sync_private, token_send_claiming_new_account, verify_commitment_is_in_state,
};
use key_protocol::key_management::key_tree::chain_index::ChainIndex;
use log::info;
@@ -79,22 +79,17 @@ async fn create_and_transfer_public_token() -> Result<()> {
}
);
// Transfer 7 tokens from supply_acc to recipient_account_id
// Transfer 7 tokens from supply_acc to recipient_account_id. `recipient_account_id` is
// still unclaimed, so this bypasses the wallet CLI (which never signs with the recipient's
// key) and signs with the recipient's own key directly.
let transfer_amount = 7;
let subcommand = TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_account_id),
to: Some(public_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
token_send_claiming_new_account(
&mut ctx,
supply_account_id,
recipient_account_id,
transfer_amount,
)
.await?;
// Check the status of the supply account after transfer
let supply_acc = get_account(&ctx, supply_account_id).await?;
@@ -962,21 +957,28 @@ async fn transfer_token_using_from_label() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Transfer token using from_label instead of from
let transfer_amount = 20;
let subcommand = TokenProgramAgnosticSubcommand::Send {
from: supply_label.into(),
to: Some(public_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
// Confirm the label resolves to the account created for it.
let resolved_sender = ctx
.wallet()
.storage()
.resolve_label(&supply_label)
.context("supply_label should resolve to an account")?;
assert_eq!(
resolved_sender,
wallet::account::AccountIdWithPrivacy::Public(supply_account_id)
);
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Transfer token from the label-resolved account. `recipient_account_id` is still
// unclaimed, so this bypasses the wallet CLI (which never signs with the recipient's key)
// and signs with the recipient's own key directly.
let transfer_amount = 20;
token_send_claiming_new_account(
&mut ctx,
supply_account_id,
recipient_account_id,
transfer_amount,
)
.await?;
let recipient_acc = get_account(&ctx, recipient_account_id).await?;
let token_holding = TokenHolding::try_from(&recipient_acc.data)?;
+2 -1
View File
@@ -9,6 +9,7 @@ fn main() {
program_outputs,
account_identities,
program_id,
dummy_inputs,
} = env::read();
let execution_state = execution_state::ExecutionState::derive_from_outputs(
@@ -17,7 +18,7 @@ fn main() {
program_outputs,
);
let output = output::compute_circuit_output(execution_state, &account_identities);
let output = output::compute_circuit_output(execution_state, &account_identities, dummy_inputs);
env::commit(&output);
}
+26 -3
View File
@@ -1,7 +1,7 @@
use lee_core::{
Commitment, CommitmentSetDigest, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey,
InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey,
PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
Commitment, CommitmentSetDigest, DummyInput, EncryptedAccountData, EncryptionScheme,
EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey,
NullifierSecretKey, PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
account::{Account, AccountId, Nonce},
compute_digest_for_path,
encryption::{ViewTag, ViewingPublicKey},
@@ -12,6 +12,7 @@ use crate::execution_state::ExecutionState;
pub fn compute_circuit_output(
execution_state: ExecutionState,
account_identities: &[InputAccountIdentity],
dummy_inputs: Vec<DummyInput>,
) -> PrivacyPreservingCircuitOutput {
let (block_validity_window, timestamp_validity_window, pda_seed_by_position, states_iter) =
execution_state.into_parts();
@@ -262,9 +263,31 @@ pub fn compute_circuit_output(
}
}
for dummy in dummy_inputs {
emit_dummy_output(&mut output, dummy);
}
output
}
fn emit_dummy_output(output: &mut PrivacyPreservingCircuitOutput, dummy: DummyInput) {
// Note: the nullifiers and commitments are generated from seeds.
// The prover is responsible for their randomness.
let nullifier = Nullifier::for_dummy(&dummy.nullifier_seed);
let commitment = Commitment::for_dummy(&nullifier, &dummy.commitment_seed);
output
.new_nullifiers
.push((nullifier, dummy.commitment_root));
output.new_commitments.push(commitment);
// Note: the encrypted post states are pushed as fed into the circuit.
// That means that the prover is responsible for managing the randomness
// so as to not reveal the padding.
//
// In particular, it is recommended to generate the ML KEM ciphertext
// explicitly as these are not uniformly random.
output.encrypted_private_post_states.push(dummy.note);
}
#[expect(
clippy::too_many_arguments,
reason = "Inputs are distinct concerns from the variant arms; bundling would be artificial"
+15
View File
@@ -19,6 +19,7 @@ pub struct PrivacyPreservingCircuitInput {
pub account_identities: Vec<InputAccountIdentity>,
/// Program ID.
pub program_id: ProgramId,
pub dummy_inputs: Vec<DummyInput>,
}
#[derive(Serialize, Deserialize, Clone)]
@@ -93,6 +94,20 @@ pub enum InputAccountIdentity {
},
}
/// A struct containing necessary data for dummy nullifier and
/// commitment generation.
#[derive(Serialize, Deserialize)]
pub struct DummyInput {
/// The seed used for generating the dummy nullifier.
pub nullifier_seed: [u8; 32],
/// The seed used for generating the dummy commitment.
pub commitment_seed: [u8; 32],
/// The dummy ciphertext, epk, and view tag.
pub note: EncryptedAccountData,
/// The dummy root.
pub commitment_root: CommitmentSetDigest,
}
impl InputAccountIdentity {
#[must_use]
pub const fn is_public(&self) -> bool {
+28 -2
View File
@@ -2,7 +2,10 @@ use borsh::{BorshDeserialize, BorshSerialize};
use risc0_zkvm::sha::{Impl, Sha256 as _};
use serde::{Deserialize, Serialize};
use crate::account::{Account, AccountId};
use crate::{
Nullifier,
account::{Account, AccountId},
};
/// A commitment to all zero data.
/// ```python
@@ -78,6 +81,15 @@ impl Commitment {
bytes.extend_from_slice(&account_bytes_with_hashed_data);
Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap())
}
#[must_use]
pub fn for_dummy(nullifier: &Nullifier, commitment_seed: &[u8; 32]) -> Self {
const DUMMY_PREFIX: &[u8; 32] = b"/LEE/v0.3/Commitment/Dummy/\x00\x00\x00\x00\x00";
let mut bytes = DUMMY_PREFIX.to_vec();
bytes.extend_from_slice(&nullifier.0);
bytes.extend_from_slice(commitment_seed);
Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap())
}
}
pub type CommitmentSetDigest = [u8; 32];
@@ -117,7 +129,7 @@ mod tests {
use risc0_zkvm::sha::{Impl, Sha256 as _};
use crate::{
Commitment, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH,
Commitment, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, Nullifier,
account::{Account, AccountId},
};
@@ -138,4 +150,18 @@ mod tests {
.unwrap();
assert_eq!(DUMMY_COMMITMENT_HASH, expected_dummy_commitment_hash);
}
#[test]
fn for_dummy_matches_pinned_value() {
let nullifier = Nullifier::for_dummy(&[0; 32]);
let commitment_seed = [1; 32];
let expected_commitment = Commitment([
106, 88, 233, 248, 28, 251, 254, 48, 62, 53, 61, 248, 25, 148, 223, 133, 108, 213, 184,
83, 73, 145, 122, 104, 89, 220, 111, 132, 40, 87, 12, 105,
]);
assert_eq!(
Commitment::for_dummy(&nullifier, &commitment_seed),
expected_commitment
);
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
)]
pub use circuit_io::{
InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput,
DummyInput, InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput,
};
pub use commitment::{
Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, MembershipProof,
+18
View File
@@ -109,6 +109,14 @@ impl Nullifier {
bytes.extend_from_slice(account_id.value());
Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap())
}
#[must_use]
pub fn for_dummy(nullifier_seed: &[u8; 32]) -> Self {
const DUMMY_PREFIX: &[u8; 32] = b"/LEE/v0.3/Nullifier/Dummy/\x00\x00\x00\x00\x00\x00";
let mut bytes = DUMMY_PREFIX.to_vec();
bytes.extend_from_slice(nullifier_seed);
Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap())
}
}
#[cfg(test)]
@@ -209,4 +217,14 @@ mod tests {
assert_eq!(account_id, expected_account_id);
}
#[test]
fn for_dummy_matches_pinned_value() {
let nullifier_seed = [0; 32];
let expected_nullifier = Nullifier([
244, 220, 48, 137, 204, 138, 180, 41, 108, 86, 40, 46, 187, 7, 232, 57, 57, 167, 143,
157, 125, 171, 137, 46, 64, 206, 191, 211, 231, 0, 11, 86,
]);
assert_eq!(Nullifier::for_dummy(&nullifier_seed), expected_nullifier);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
pub use lee_core::{
GENESIS_BLOCK_ID, SharedSecretKey,
account::{Account, AccountId, Data},
account::{Account, AccountId, Balance, Data},
encryption::EphemeralPublicKey,
program::ProgramId,
};
@@ -2,7 +2,8 @@ use std::collections::{HashMap, VecDeque};
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput,
DummyInput, InputAccountIdentity, PrivacyPreservingCircuitInput,
PrivacyPreservingCircuitOutput,
account::AccountWithMetadata,
program::{ChainedCall, InstructionData, ProgramId, ProgramOutput},
};
@@ -69,6 +70,22 @@ pub fn execute_and_prove(
instruction_data: InstructionData,
account_identities: Vec<InputAccountIdentity>,
program_with_dependencies: &ProgramWithDependencies,
) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> {
execute_and_prove_with_padded_inputs(
pre_states,
instruction_data,
account_identities,
vec![],
program_with_dependencies,
)
}
pub fn execute_and_prove_with_padded_inputs(
pre_states: Vec<AccountWithMetadata>,
instruction_data: InstructionData,
account_identities: Vec<InputAccountIdentity>,
dummy_inputs: Vec<DummyInput>,
program_with_dependencies: &ProgramWithDependencies,
) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> {
let ProgramWithDependencies {
program: initial_program,
@@ -127,6 +144,7 @@ pub fn execute_and_prove(
program_outputs,
account_identities,
program_id: program_with_dependencies.program.id(),
dummy_inputs,
};
env_builder.write(&circuit_input).unwrap();
+52 -8
View File
@@ -199,14 +199,6 @@ impl V03State {
self.programs.insert(program.id(), program);
}
/// Seeds a single genesis account that is not produced by any transaction
/// (e.g. the cross-zone inbox config or a bridge-lock holding). Lets the
/// sequencer and indexer seed identical zone-specific state after building
/// the shared initial state.
pub fn insert_genesis_account(&mut self, account_id: AccountId, account: Account) {
self.public_state.insert(account_id, account);
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
let StateDiff {
signer_account_ids,
@@ -292,6 +284,58 @@ impl V03State {
self.private_state.0.digest()
}
/// Order-independent fingerprint of the genesis-relevant state: the public
/// account set, the deployed program set, and the commitment-set digest.
///
/// The sequencer and the indexer build the directly-seeded part of genesis
/// (base builtins plus any directly-seeded accounts) separately from their own
/// configs, so a divergence there would otherwise go unnoticed. Both nodes log
/// this at startup; equal values mean the two genesis states agree. Entries are
/// sorted by id before hashing, so the value does not depend on `HashMap`
/// iteration order.
#[must_use]
pub fn genesis_fingerprint(&self) -> [u8; 32] {
use sha2::{Digest as _, Sha256};
// Destructure so adding a `V03State` field forces a decision here about
// whether it belongs in the genesis fingerprint.
let Self {
public_state,
private_state,
programs,
} = self;
let mut accounts: Vec<(&AccountId, &Account)> = public_state.iter().collect();
accounts.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()));
let mut program_ids: Vec<ProgramId> = programs.keys().copied().collect();
program_ids.sort_unstable();
let account_count = u64::try_from(accounts.len()).expect("account count fits in u64");
let program_count = u64::try_from(program_ids.len()).expect("program count fits in u64");
let mut hasher = Sha256::new();
hasher.update(account_count.to_le_bytes());
for (id, account) in accounts {
hasher.update(id.as_ref());
let bytes = borsh::to_vec(account).expect("Account is BorshSerialize");
let len = u64::try_from(bytes.len()).expect("account encoding fits in u64");
hasher.update(len.to_le_bytes());
hasher.update(&bytes);
}
hasher.update(program_count.to_le_bytes());
for id in program_ids {
for word in id {
hasher.update(word.to_le_bytes());
}
}
hasher.update(private_state.0.digest());
let mut out = [0_u8; 32];
out.copy_from_slice(&hasher.finalize());
out
}
pub(crate) fn check_commitments_are_new(
&self,
new_commitments: &[Commitment],
+1
View File
@@ -9,6 +9,7 @@ pub mod config;
pub mod transaction;
// Module for tests utility functions
//
// TODO: Compile only for tests
pub mod test_utils;
+2
View File
@@ -14,4 +14,6 @@ programs.workspace = true
cross_zone_inbox_core.workspace = true
bridge_lock_core.workspace = true
ping_core.workspace = true
wrapped_token_core.workspace = true
serde.workspace = true
risc0-zkvm.workspace = true
+64 -33
View File
@@ -2,6 +2,12 @@
//! machine (`lee`), kept out of the guest-pure cores. Mirrors `system_accounts`:
//! it resolves builtin program ids and bakes them into transactions and genesis
//! accounts for the watcher (sequencer) and verifier (indexer).
//!
//! This crate is the reference LEZ-to-LEZ adapter: it re-derives each delivery
//! byte-for-byte from a peer LEZ zone's finalized blocks, valid only because the
//! peer runs identical LEZ code. A non-LEZ peer needs a separate adapter with its
//! own block-reading, emission-extraction, delivery-building, and trust model; a
//! shared trait is best lifted from that first real adapter, not from this one.
use std::collections::BTreeMap;
@@ -11,9 +17,10 @@ use cross_zone_inbox_core::{
inbox_seen_shard_account_id,
};
use lee_core::{
account::{Account, AccountId},
account::{Account, AccountId, Balance},
program::ProgramId,
};
use serde::Serialize;
/// The cross-zone emission fields a watcher or verifier reads off a source
/// transaction, common to every emitter program.
@@ -136,54 +143,78 @@ pub fn build_dispatch_from_emission(
build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids)
}
/// Builds the inbox config account a zone seeds into genesis state.
///
/// Lets the inbox guest authorize inbound peer messages. The sequencer and
/// indexer seed the same account from the same config, keeping their replayed
/// state consistent.
#[must_use]
pub fn build_inbox_config_account(
self_zone: ZoneId,
cross_zone: &CrossZoneConfig,
) -> (AccountId, Account) {
let inbox_id = programs::cross_zone_inbox().id();
/// The inbox config a zone derives from its cross-zone config: the per-peer target
/// allowlists plus its own zone id.
fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig {
let mut allowed_targets = BTreeMap::new();
for peer in &cross_zone.peers {
allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone());
}
let config = InboxConfig {
InboxConfig {
self_zone,
allowed_peers: BTreeMap::new(),
allowed_targets,
};
}
}
let account = Account {
program_owner: inbox_id,
balance: 0,
data: config
.to_bytes()
.try_into()
.expect("inbox config fits in account data"),
nonce: 0_u128.into(),
};
(inbox_config_account_id(inbox_id), account)
/// The genesis transaction that initializes this zone's inbox config PDA.
///
/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the
/// same account on every node, keeping their state consistent.
#[must_use]
pub fn build_inbox_init_config_tx(
self_zone: ZoneId,
cross_zone: &CrossZoneConfig,
) -> lee::PublicTransaction {
let inbox_id = programs::cross_zone_inbox().id();
genesis_public_tx(
inbox_id,
vec![inbox_config_account_id(inbox_id)],
Instruction::InitConfig(inbox_config(self_zone, cross_zone)),
)
}
/// Builds the genesis holding account funding a holder's bridgeable balance.
///
/// Owned by `bridge_lock`, data is the LE balance. Not produced by any
/// transaction, so the sequencer and indexer both seed it through this one
/// builder.
/// A real native balance owned by `bridge_lock`, which can debit it on a lock; it
/// is conserved like any other balance. Not produced by any transaction, so the
/// sequencer and indexer both seed it through this one builder.
#[must_use]
pub fn build_holding_account(holder: AccountId, amount: u128) -> (AccountId, Account) {
pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, Account) {
let account = Account {
program_owner: programs::bridge_lock().id(),
data: bridge_lock_core::balance_bytes(amount)
.to_vec()
.try_into()
.expect("balance fits in account data"),
balance: amount,
..Default::default()
};
(holder, account)
}
/// The genesis transaction that pins the cross-zone inbox as the wrapped-token
/// minter, without importing the inbox id into the guest.
#[must_use]
pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction {
let wrapped_token_id = programs::wrapped_token().id();
genesis_public_tx(
wrapped_token_id,
vec![wrapped_token_core::config_account_id(wrapped_token_id)],
wrapped_token_core::Instruction::InitConfig {
minter: programs::cross_zone_inbox().id(),
},
)
}
/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction`
/// on `program_id` over `account_ids`.
fn genesis_public_tx<I: Serialize>(
program_id: ProgramId,
account_ids: Vec<AccountId>,
instruction: I,
) -> lee::PublicTransaction {
let message =
lee::public_transaction::Message::try_new(program_id, account_ids, vec![], instruction)
.expect("genesis instruction must serialize");
lee::PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}
+7 -8
View File
@@ -27,17 +27,16 @@ impl IndexerStore {
/// Creates files if necessary.
pub fn open_db(location: &Path, genesis_seed: Vec<(AccountId, Account)>) -> Result<Self> {
#[cfg(not(feature = "testnet"))]
let mut initial_state = testnet_initial_state::initial_state();
let base = testnet_initial_state::initial_state();
#[cfg(feature = "testnet")]
let mut initial_state = testnet_initial_state::initial_state_testnet();
let base = testnet_initial_state::initial_state_testnet();
// Seed any zone-specific genesis accounts (the cross-zone inbox config and
// bridge-lock holdings) so the indexer's replayed state matches the
// sequencer's; none are produced by a transaction.
for (account_id, account) in genesis_seed {
initial_state.insert_genesis_account(account_id, account);
}
// Seed any zone-specific genesis accounts (the bridge-lock holdings) so the
// indexer's replayed state matches the sequencer's; none are produced by a
// transaction. Cross-zone programs are base builtins, and their config
// accounts are reconstructed by replaying the genesis block's InitConfig txs.
let initial_state = base.with_public_accounts(genesis_seed);
let dbio = RocksDBIO::open_or_create(location, &initial_state)?;
let current_state = dbio.final_state()?;
+92 -24
View File
@@ -12,7 +12,7 @@ use cross_zone_inbox_core::{
};
use futures::StreamExt as _;
use lee::PublicKey;
use log::{error, info};
use log::{debug, error, info};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
@@ -51,8 +51,7 @@ impl PeerBlocks {
}
/// The highest block id this reader has finalized for `zone`, or `None` if it
/// has read nothing yet. Used to tell forgery (we have read past the
/// referenced block and it is absent) from lag (we simply have not caught up).
/// has read nothing yet.
async fn highest_seen(&self, zone: ZoneId) -> Option<u64> {
self.chains
.read()
@@ -65,13 +64,17 @@ impl PeerBlocks {
/// The indexer-side Option B verifier.
///
/// For every cross-zone dispatch in a block it re-derives the transaction from
/// the peer's finalized block and rejects it if the bytes differ (a forgery) or
/// the message was already delivered (a replay), so delivery no longer relies on
/// trusting the sequencer.
/// the peer's finalized block and rejects it if the bytes differ (a forgery), so
/// delivery no longer relies on trusting the sequencer. A replay of an
/// already-delivered message is accepted, since the inbox no-ops it on chain.
#[derive(Clone)]
pub struct CrossZoneVerifier {
self_zone: ZoneId,
/// Pinned block-signing key per peer zone, enforced during re-derivation.
/// One key per peer is sufficient while a zone has a single sequencer; key
/// sets with rotation come in with decentralized sequencing. The pin is
/// largely redundant given Bedrock's turn-based write authorization, so it is
/// optional: a peer with no configured key is not signature-checked.
peer_pubkeys: HashMap<ZoneId, PublicKey>,
peers: PeerBlocks,
seen: Arc<RwLock<HashSet<MessageKey>>>,
@@ -112,9 +115,17 @@ impl CrossZoneVerifier {
})
}
/// Verifies every cross-zone dispatch in a block, returning `Err` on the
/// first forged or replayed dispatch. The caller halts ingestion on error.
pub async fn verify_block(&self, block: &Block) -> Result<()> {
/// Verifies every cross-zone dispatch in a block, returning `Err` on the first
/// forged dispatch (the caller halts ingestion) or the keys to mark seen.
///
/// The caller MUST record the returned keys via [`Self::record_seen`] only
/// after the block applies, so the seen-set mirrors the inbox's on-chain
/// seen-shard. Marking a key from a block that never applies would let a later
/// forged dispatch reuse it to skip re-derivation while the inbox delivers the
/// forgery. A key already seen is a replay the inbox no-ops, so it is accepted
/// without re-derivation rather than halting on a legitimate re-delivery.
pub async fn verify_block(&self, block: &Block) -> Result<Vec<MessageKey>> {
let mut verified = Vec::new();
for tx in &block.body.transactions {
let Some(msg) = Self::decode_dispatch(tx) else {
continue;
@@ -122,10 +133,13 @@ impl CrossZoneVerifier {
let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index);
if self.seen.read().await.contains(&key) {
bail!(
"cross-zone replay: message {} re-delivered",
hex::encode(key)
debug!(
"Skipping already-seen cross-zone dispatch from zone {} block {} tx {} (replay no-op)",
hex::encode(msg.src_zone),
msg.src_block_id,
msg.src_tx_index
);
continue;
}
let expected = self.rederive(&msg).await?;
@@ -138,15 +152,25 @@ impl CrossZoneVerifier {
);
}
self.seen.write().await.insert(key);
info!(
"Verified cross-zone dispatch from zone {} block {} tx {}",
hex::encode(msg.src_zone),
msg.src_block_id,
msg.src_tx_index
);
verified.push(key);
}
Ok(())
Ok(verified)
}
/// Marks the given dispatch keys seen, so a later replay of them is accepted
/// without re-derivation. Call only after the block that carried them has been
/// applied on chain (see [`Self::verify_block`]).
pub async fn record_seen(&self, keys: Vec<MessageKey>) {
if keys.is_empty() {
return;
}
self.seen.write().await.extend(keys);
}
/// Decodes a transaction into the cross-zone message it dispatches, or `None`
@@ -162,7 +186,9 @@ impl CrossZoneVerifier {
&public_tx.message().instruction_data,
) {
Ok(InboxInstruction::Dispatch(msg)) => Some(msg),
Err(_) => None,
// Only a dispatch carries a cross-zone message to re-derive; a genesis
// `InitConfig` is not verifier-relevant.
Ok(InboxInstruction::InitConfig(_)) | Err(_) => None,
}
}
@@ -225,11 +251,6 @@ impl CrossZoneVerifier {
/// If the block is cached, return it. If our peer reader has already
/// finalized past `block_id` and we still do not have it, the reference is to
/// a block that does not exist on the peer chain, a forgery, so reject now.
/// Otherwise the reader simply has not caught up yet: keep waiting, since a
/// legitimate dispatch is only injected after its peer block finalized and
/// our reader of the same finalized chain will see it too. Rejecting on a
/// timeout here would turn a lagging reader into a permanent halt of an
/// honest message.
async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result<Block> {
let mut waited = Duration::ZERO;
loop {
@@ -474,7 +495,7 @@ mod tests {
}
#[tokio::test]
async fn rejects_replayed_dispatch() {
async fn accepts_replayed_dispatch_as_noop() {
let verifier = verifier();
verifier
.peers
@@ -485,15 +506,62 @@ mod tests {
.await;
let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier
let keys = verifier
.verify_block(&first)
.await
.expect("first delivery verifies");
// Mark the delivery seen, as the ingest loop does once the block applies.
verifier.record_seen(keys).await;
// Replace the peer block with a different emission so re-deriving the
// replay would mismatch. The replay must still be accepted, proving it is
// the seen-key short-circuit (the inbox no-ops it on chain) and not a
// successful re-derivation.
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"different")]),
)
.await;
let replay = produce_dummy_block(10, None, vec![dispatch(b"hi")]);
let err = verifier.verify_block(&replay).await.unwrap_err();
verifier
.verify_block(&replay)
.await
.expect("a replay is accepted as an on-chain no-op");
}
#[tokio::test]
async fn unaccepted_dispatch_does_not_poison_seen() {
// A dispatch verified in a block that never applies (e.g. one that parks)
// must not be marked seen. Otherwise a later forged dispatch could reuse
// its key to skip re-derivation, while the inbox, never having recorded
// the key on chain, would deliver the forgery.
let verifier = verifier();
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
// The dispatch verifies, but the block is not applied, so record_seen is
// not called (the ingest loop records only after an Applied outcome).
let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier
.verify_block(&first)
.await
.expect("dispatch verifies");
// A forged dispatch reusing the same key (same src zone, block, tx index)
// with a different payload must still be re-derived and rejected, since
// its key was never recorded as seen.
let forged = produce_dummy_block(10, None, vec![dispatch(b"forged")]);
let err = verifier.verify_block(&forged).await.unwrap_err();
assert!(
err.to_string().contains("replay"),
err.to_string().contains("forged"),
"unexpected error: {err}"
);
}
+34 -32
View File
@@ -90,23 +90,15 @@ impl IndexerCore {
);
let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone());
// Genesis accounts the indexer must seed to match the sequencer's state,
// since none are produced by a transaction: the cross-zone inbox config
// and any bridge-lock holdings. Both go through the same builders the
// sequencer uses, so the states are byte-identical.
let mut genesis_seed = Vec::new();
if let Some(cross_zone) = config.cross_zone.as_ref() {
let self_zone: [u8; 32] = *config.channel_id.as_ref();
genesis_seed.push(cross_zone::build_inbox_config_account(
self_zone, cross_zone,
));
}
for holding in &config.bridge_lock_holdings {
genesis_seed.push(cross_zone::build_holding_account(
holding.holder,
holding.amount,
));
}
// Cross-zone programs are base builtins, and their config accounts are
// reconstructed by replaying the genesis block's InitConfig transactions;
// neither is seeded here. Only bridge-lock holdings (source side), not
// produced by any transaction, are still seeded directly.
let genesis_accounts: Vec<_> = config
.bridge_lock_holdings
.iter()
.map(|holding| cross_zone::build_holding_account(holding.holder, holding.amount))
.collect();
// Option B verifier: re-derives each cross-zone dispatch from the peer's
// finalized blocks. `None` when cross-zone messaging is disabled.
@@ -114,7 +106,7 @@ impl IndexerCore {
Ok(Self {
zone_indexer: Arc::new(zone_indexer),
store: IndexerStore::open_db(&home, genesis_seed)?,
store: IndexerStore::open_db(&home, genesis_accounts)?,
node,
config,
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),
@@ -300,23 +292,33 @@ impl IndexerCore {
};
// Option B: re-derive and verify every cross-zone dispatch
// before applying the block. A forged or replayed dispatch
// halts ingestion rather than persisting an invalid state.
if let Some(verifier) = &self.verifier
&& let Err(err) = verifier.verify_block(&block).await
{
error!(
"Cross-zone verification failed for block {}: {err:#}. Halting indexer ingestion.",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"cross-zone verification failed: {err:#}"
)));
return;
}
// before applying the block. A forged dispatch halts ingestion
// rather than persisting an invalid state; a replay is accepted
// since the inbox no-ops it on chain. The verified keys are
// marked seen only once the block applies (below), so a block
// that does not apply cannot poison the seen-set.
let verified_keys = match &self.verifier {
Some(verifier) => match verifier.verify_block(&block).await {
Ok(keys) => keys,
Err(err) => {
error!(
"Cross-zone verification failed for block {}: {err:#}. Halting indexer ingestion.",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"cross-zone verification failed: {err:#}"
)));
return;
}
},
None => Vec::new(),
};
match self.store.accept_block(&block, slot).await {
Ok(AcceptOutcome::Applied) => {
if let Some(verifier) = &self.verifier {
verifier.record_seen(verified_keys).await;
}
retry_gate.reset();
info!("Indexed L2 block {}", block.header.block_id);
self.set_status(IndexerSyncStatus::syncing());
-20
View File
@@ -39,30 +39,10 @@ pub const fn escrow_seed() -> PdaSeed {
PdaSeed::new(ESCROW_SEED_DOMAIN)
}
/// Reads a bridgeable balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub const fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(7)), 7);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn escrow_is_stable() {
let id: ProgramId = [4; 8];
+20 -12
View File
@@ -1,4 +1,4 @@
use bridge_lock_core::{Instruction, balance_bytes, escrow_account_id, escrow_seed, read_balance};
use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed};
use cross_zone_outbox_core::Instruction as OutboxInstruction;
use lee_core::{
account::AccountWithMetadata,
@@ -36,7 +36,10 @@ fn main() {
let WrappedInstruction::Mint {
amount: mint_amount,
..
} = decode_mint(&payload);
} = decode_mint(&payload)
else {
panic!("bridge_lock payload must be a wrapped-token mint");
};
assert_eq!(
mint_amount, amount,
"locked amount must equal the wrapped mint amount"
@@ -47,6 +50,10 @@ fn main() {
.expect("Lock requires holder, escrow, and outbox accounts");
assert!(holder.is_authorized, "holder must authorize the lock");
// The holder holding is bridge_lock-owned, so bridge_lock may debit its native
// balance directly (state-machine rule 5). This also pins the transfer to a
// genuine holding: a caller cannot substitute an account owned by some other
// program to emit the mint without an actual lock.
assert_eq!(
holder.account.program_owner, self_program_id,
"holder account must be a bridge_lock holding"
@@ -57,25 +64,26 @@ fn main() {
"second account must be the escrow PDA"
);
let holder_new = read_balance(&holder.account.data.clone().into_inner())
// Move the real native balance holder -> escrow. bridge_lock owns both accounts,
// so it debits the holder and credits the escrow directly; conservation holds
// because the same amount moves between them.
let holder_new = holder
.account
.balance
.checked_sub(amount)
.expect("insufficient balance to lock");
let escrow_new = read_balance(&escrow.account.data.clone().into_inner())
let escrow_new = escrow
.account
.balance
.checked_add(amount)
.expect("escrow balance overflow");
let mut holder_account = holder.account.clone();
holder_account.data = balance_bytes(holder_new)
.to_vec()
.try_into()
.expect("balance fits in account data");
holder_account.balance = holder_new;
let holder_post = AccountPostState::new(holder_account);
let mut escrow_account = escrow.account.clone();
escrow_account.data = balance_bytes(escrow_new)
.to_vec()
.try_into()
.expect("balance fits in account data");
escrow_account.balance = escrow_new;
let escrow_post =
AccountPostState::new_claimed_if_default(escrow_account, Claim::Pda(escrow_seed()));
+12 -1
View File
@@ -116,6 +116,10 @@ impl SeenShard {
pub enum Instruction {
/// Delivers a finalized peer message to its target program.
Dispatch(CrossZoneMessage),
/// Initializes the inbox config account at genesis. Written once, into a
/// default (unclaimed) config PDA; the guest refuses a non-default pre-state,
/// so it cannot be re-run to overwrite the allowlists.
InitConfig(InboxConfig),
}
/// Content-addressed replay key for a delivered message.
@@ -142,7 +146,14 @@ pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> M
/// The config account holding the allowlists.
#[must_use]
pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED))
AccountId::for_public_pda(&inbox_id, &inbox_config_seed())
}
/// Seed of the config PDA, exposed so the guest can claim the account when it
/// initializes the config at genesis.
#[must_use]
pub const fn inbox_config_seed() -> PdaSeed {
PdaSeed::new(INBOX_CONFIG_SEED)
}
/// The seen-set shard for the `(src_zone, epoch)` the message falls in.
+85 -5
View File
@@ -1,10 +1,13 @@
use cross_zone_inbox_core::{
InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id,
inbox_seen_shard_seed, message_key,
CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id,
inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, message_key,
};
use lee_core::{
account::AccountWithMetadata,
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
account::{Account, AccountWithMetadata},
program::{
AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput,
read_lee_inputs,
},
};
fn unchanged(pre: &AccountWithMetadata) -> AccountPostState {
@@ -27,8 +30,32 @@ fn main() {
"Inbox is only invoked as a top-level sequencer-origin transaction"
);
let Instruction::Dispatch(msg) = instruction;
match instruction {
Instruction::Dispatch(msg) => dispatch(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
&msg,
),
Instruction::InitConfig(config) => init_config(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
&config,
),
}
}
/// Delivers a finalized peer message to its target program, no-op on replay.
fn dispatch(
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
msg: &CrossZoneMessage,
) {
assert!(
msg.l1_inclusion_witness.is_none(),
"l1_inclusion_witness must be None in v1"
@@ -123,3 +150,56 @@ fn main() {
.with_chained_calls(chained_calls)
.write();
}
/// Writes the inbox config (peer + target allowlists) into the config PDA exactly
/// once at genesis.
fn init_config(
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
config: &InboxConfig,
) {
// pre_states: [config PDA].
let [config_meta] = <[AccountWithMetadata; 1]>::try_from(pre_states)
.expect("InitConfig requires the config account");
assert_eq!(
config_meta.account_id,
inbox_config_account_id(self_program_id),
"account must be the inbox config PDA"
);
// Init-once, idempotent under genesis replay: a `default` config is a first
// init; an already-owned config must already hold exactly these allowlists (the
// genesis block is replayed onto seeded state during multi-sequencer
// reconstruction), otherwise reject a post-genesis attempt to change them.
// `new_claimed_if_default` alone would not stop the owning program from
// rewriting its own config data on a later call.
if config_meta.account != Account::default() {
assert_eq!(
config_meta.account.program_owner, self_program_id,
"inbox config PDA is owned by another program"
);
assert_eq!(
config_meta.account.data.clone().into_inner(),
config.to_bytes(),
"inbox config already initialized with different allowlists"
);
}
let mut config_account = config_meta.account.clone();
config_account.data = config
.to_bytes()
.try_into()
.expect("inbox config fits in account data");
let config_post =
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(inbox_config_seed()));
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![config_meta],
vec![config_post],
)
.write();
}
@@ -19,6 +19,12 @@ pub enum Instruction {
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
/// holding PDA.
Mint { recipient: [u8; 32], amount: u128 },
/// Pins `minter` (the cross-zone inbox) as the authorized minter, written once
/// into a default config PDA at genesis. The guest refuses a non-default
/// pre-state, so it cannot be re-run to hijack the minter.
///
/// Required accounts (1): the wrapped-token config PDA.
InitConfig { minter: ProgramId },
}
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at
+86 -4
View File
@@ -1,10 +1,10 @@
use lee_core::{
account::AccountWithMetadata,
account::{Account, AccountWithMetadata},
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
};
use wrapped_token_core::{
Instruction, balance_bytes, config_account_id, holding_account_id, holding_seed, read_balance,
read_minter,
Instruction, balance_bytes, config_account_id, config_seed, holding_account_id, holding_seed,
minter_bytes, read_balance, read_minter,
};
fn main() {
@@ -18,8 +18,33 @@ fn main() {
instruction_words,
) = read_lee_inputs::<Instruction>();
let Instruction::Mint { recipient, amount } = instruction;
match instruction {
Instruction::Mint { recipient, amount } => mint(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
recipient,
amount,
),
Instruction::InitConfig { minter } => init_config(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
minter,
),
}
}
fn mint(
self_program_id: lee_core::program::ProgramId,
caller_program_id: Option<lee_core::program::ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
recipient: [u8; 32],
amount: u128,
) {
// pre_states: [config PDA, recipient holding PDA].
let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states)
.expect("Mint requires the config and recipient holding accounts");
@@ -68,3 +93,60 @@ fn main() {
)
.write();
}
/// Writes the authorized minter into the config PDA exactly once at genesis.
fn init_config(
self_program_id: lee_core::program::ProgramId,
caller_program_id: Option<lee_core::program::ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
minter: lee_core::program::ProgramId,
) {
assert!(
caller_program_id.is_none(),
"InitConfig is a top-level genesis transaction"
);
// pre_states: [config PDA].
let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states)
.expect("InitConfig requires the config account");
assert_eq!(
config.account_id,
config_account_id(self_program_id),
"account must be the wrapped-token config PDA"
);
// Init-once, idempotent under genesis replay: a `default` config is a first
// init; an already-owned config must already hold exactly this minter (the
// genesis block is replayed onto seeded state during multi-sequencer
// reconstruction), otherwise reject a post-genesis attempt to set a different
// minter. `new_claimed_if_default` alone would not stop the owning program from
// rewriting its own config data on a later call.
if config.account != Account::default() {
assert_eq!(
config.account.program_owner, self_program_id,
"wrapped-token config PDA is owned by another program"
);
assert_eq!(
config.account.data.clone().into_inner(),
minter_bytes(minter).to_vec(),
"wrapped-token config already initialized with a different minter"
);
}
let mut config_account = config.account.clone();
config_account.data = minter_bytes(minter)
.to_vec()
.try_into()
.expect("minter id fits in account data");
let config_post =
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed()));
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![config],
vec![config_post],
)
.write();
}
+4 -4
View File
@@ -10,7 +10,7 @@ use bytesize::ByteSize;
use common::config::BasicAuth;
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
use humantime_serde;
use lee::AccountId;
use lee::{AccountId, Balance};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use serde::{Deserialize, Serialize};
use url::Url;
@@ -21,15 +21,15 @@ use url::Url;
pub enum GenesisAction {
SupplyAccount {
account_id: AccountId,
balance: u128,
balance: Balance,
},
SupplyBridgeAccount {
balance: u128,
balance: Balance,
},
/// Seeds a bridge-lock holder's initial bridgeable balance into genesis state.
SupplyBridgeLockHolding {
holder: AccountId,
amount: u128,
amount: Balance,
},
}
+71 -47
View File
@@ -217,15 +217,16 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
// Before producing, verify our local state still belongs to the chain
// the channel serves and replay any channel blocks we are missing
// (e.g. from other sequencers).
let channel_was_empty =
let channel_absent =
Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start)
.await
.expect("Failed to verify/reconstruct sequencer state from Bedrock");
// Publish our blocks only when bootstrapping an empty channel. If the
// channel already has blocks (another sequencer bootstrapped it), we
// adopted them during reconstruction instead.
if is_fresh_start && channel_was_empty {
// Publish our blocks only when we are bootstrapping a channel that does
// not exist yet (no channel tip). If the channel already exists (another
// sequencer created it), we adopted its blocks during reconstruction
// instead; republishing then would fork the channel with our own copies.
if is_fresh_start && channel_absent {
let mut pending_blocks = store
.get_all_blocks()
.filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending))
@@ -269,7 +270,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
/// `state`/`store`, recording each block's L1 inscription slot as the new
/// anchor. Fails (never parks) on any divergence.
///
/// Returns whatever channel was empty or not.
/// Returns whether the channel does not exist yet (has no tip), i.e. whether
/// this sequencer is the one that must bootstrap-publish its own blocks.
async fn verify_and_reconstruct(
publisher: &BP,
store: &SequencerStore,
@@ -351,7 +353,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.await
.context("Failed to read channel history for reconstruction")?;
let mut messages = std::pin::pin!(messages);
let mut channel_is_empty = true;
while let Some((message, slot)) = messages.next().await {
if let Some(check) = &mut consistency_check
&& let Some(ChainConsistency::Inconsistent(mismatch)) =
@@ -369,7 +370,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
slot.into_inner()
)
})?;
channel_is_empty = false;
// Locked per message (not across the stream `await`): concurrent
// follow events interleave safely — both paths apply idempotently
// and persist under this same lock.
@@ -377,7 +377,12 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
Self::apply_reconstructed_block(store, &mut chain, &block, slot)?;
}
Ok(channel_is_empty)
// The channel exists once it has a tip; only when it has none is this
// sequencer the one bootstrapping it. This is deliberately not the
// reconstruction scan's view above, which reads only finalized history
// (up to LIB) and so reports "empty" while finality lags even though the
// channel already holds unfinalized blocks from another sequencer.
Ok(channel_tip_slot.is_none())
}
/// Applies a single channel block during reconstruction: idempotent for
@@ -1088,33 +1093,22 @@ fn replay_unfulfilled_deposit_events(
});
}
/// The pre-genesis state: `testnet_initial_state` plus accounts seeded outside
/// any transaction (bridge-lock holdings, the cross-zone inbox config).
/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings,
/// the only accounts seeded outside any transaction. Cross-zone config is seeded
/// by genesis `InitConfig` transactions and reconstructed by replaying them.
fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
#[cfg(not(feature = "testnet"))]
let mut state = testnet_initial_state::initial_state();
let base = testnet_initial_state::initial_state();
#[cfg(feature = "testnet")]
let mut state = testnet_initial_state::initial_state_testnet();
let base = testnet_initial_state::initial_state_testnet();
// Seed bridge-lock holder balances directly: they are not produced by any tx.
for action in &config.genesis {
if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action {
let (holder_id, account) = cross_zone::build_holding_account(*holder, *amount);
state.insert_genesis_account(holder_id, account);
}
}
// Seed this zone's cross-zone inbox config so the inbox guest can authorize
// inbound peer messages (zone-specific config, not produced by any tx).
if let Some(cross_zone) = &config.cross_zone {
let self_zone = *config.bedrock_config.channel_id.as_ref();
let (config_id, config_account) =
cross_zone::build_inbox_config_account(self_zone, cross_zone);
state.insert_genesis_account(config_id, config_account);
}
state
// Bridge-lock holder balances belong to the source side and are not produced by
// any transaction, so seed them directly. Cross-zone config is seeded by genesis
// InitConfig transactions in `build_genesis_state`, not here.
let holdings = bridge_lock_holdings(&config.genesis)
.map(|(holder, amount)| cross_zone::build_holding_account(holder, amount));
base.with_public_accounts(holdings)
}
/// Builds the initial genesis state from [`build_initial_state`] plus configured
@@ -1124,22 +1118,42 @@ fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
let mut state = build_initial_state(config);
let genesis_txs = config
.genesis
.iter()
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Force-inserted in `build_initial_state`: bridge_lock has no mint transaction.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
// Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's.
info!(
"Genesis fingerprint: {}",
hex::encode(state.genesis_fingerprint())
);
// Config txs seed the config accounts by transaction, so every node
// reconstructs them by replaying the genesis block. The wrapped-token minter is
// initialized on every zone (wrapped_token is a builtin), since its InitConfig
// is user-callable and a config PDA left default would be claimable by anyone as
// the first initializer (a minter hijack). The inbox allowlist is initialized
// only on receiving zones; the inbox is sequencer-only, so its default config
// PDA is not user-claimable, merely unused until the zone receives.
let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx());
let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| {
let self_zone = *config.bedrock_config.channel_id.as_ref();
cross_zone::build_inbox_init_config_tx(self_zone, cross_zone)
});
let supply_txs = config.genesis.iter().filter_map(|action| match action {
GenesisAction::SupplyAccount {
account_id,
balance,
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Seeded directly in `build_initial_state` (holdings via `build_holding_account`), not a
// genesis tx.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
});
let genesis_txs = wrapped_token_config_tx
.chain(inbox_config_tx)
.chain(supply_txs)
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
state
@@ -1152,6 +1166,16 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
(state, genesis_txs)
}
/// Bridge-lock holder balances configured for this zone's genesis.
fn bridge_lock_holdings(
genesis: &[GenesisAction],
) -> impl Iterator<Item = (lee::AccountId, lee::Balance)> + '_ {
genesis.iter().filter_map(|action| match action {
GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)),
GenesisAction::SupplyAccount { .. } | GenesisAction::SupplyBridgeAccount { .. } => None,
})
}
/// Whether a program may only be invoked by sequencer-origin transactions.
///
/// The cross-zone inbox is injected solely by the watcher; a user-submitted call
-1
View File
@@ -10,7 +10,6 @@ lee.workspace = true
lee_core.workspace = true
system_accounts.workspace = true
programs.workspace = true
wrapped_token_core.workspace = true
serde.workspace = true
+5 -22
View File
@@ -258,30 +258,9 @@ fn initial_public_accounts() -> HashMap<AccountId, Account> {
.into_iter()
.map(|clock_id| (clock_id, system_accounts::clock_account())),
)
.chain(std::iter::once(wrapped_token_config_account()))
.collect()
}
/// The wrapped-token config account.
///
/// Seeded so the `wrapped_token` guest can pin its authorized minter (the
/// cross-zone inbox) without importing the inbox id. Fixed for every zone, so it
/// lives in the shared initial state.
fn wrapped_token_config_account() -> (AccountId, Account) {
let wrapped_token_id = programs::wrapped_token().id();
(
wrapped_token_core::config_account_id(wrapped_token_id),
Account {
program_owner: wrapped_token_id,
data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id())
.to_vec()
.try_into()
.expect("minter id fits in account data"),
..Default::default()
},
)
}
fn initial_programs() -> Vec<Program> {
vec![
programs::authenticated_transfer(),
@@ -292,8 +271,12 @@ fn initial_programs() -> Vec<Program> {
programs::vault(),
programs::faucet(),
programs::bridge(),
programs::cross_zone_outbox(),
// Cross-zone programs are builtins: their bytecode is baked into every node,
// so registering them in the base state (rather than shipping ELFs through
// the genesis block, which exceeds the inscription size limit) keeps the two
// nodes in lock-step with nothing to desync.
programs::cross_zone_inbox(),
programs::cross_zone_outbox(),
programs::ping_sender(),
programs::ping_receiver(),
programs::bridge_lock(),
+2 -2
View File
@@ -191,7 +191,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded(
let transfer = NativeTokenTransfer(&wallet);
match block_on(transfer.send_shielded_transfer_to_outer_account(
from_mention.into_public_identity(from_id),
from_mention.into_public_identity(from_id, true),
to_npk,
to_vpk,
to_identifier,
@@ -464,7 +464,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded_owned(
let transfer = NativeTokenTransfer(&wallet);
match block_on(transfer.send_shielded_transfer(
from_mention.into_public_identity(from_id),
from_mention.into_public_identity(from_id, true),
to_id,
amount,
)) {
+139 -12
View File
@@ -4,11 +4,13 @@ use anyhow::Result;
use keycard_wallet::KeycardWallet;
use lee::{AccountId, PrivateKey, PublicKey, Signature};
use lee_core::{
Commitment, CommitmentSetDigest, Identifier, InputAccountIdentity, MembershipProof,
NullifierPublicKey, NullifierSecretKey, SharedSecretKey,
account::{AccountWithMetadata, Nonce},
Commitment, CommitmentSetDigest, DummyInput, Identifier, InputAccountIdentity, MembershipProof,
NullifierPublicKey, NullifierSecretKey, PrivateAccountKind, SharedSecretKey,
account::{Account, AccountWithMetadata, Nonce},
compute_digest_for_path,
encryption::{ViewTag, ViewingPublicKey},
encryption::{
Ciphertext, EncryptedAccountData, MlKem768EncapsulationKey, ViewTag, ViewingPublicKey,
},
};
use rand::{RngCore as _, rngs::OsRng};
@@ -192,6 +194,14 @@ pub struct AccountManager {
}
impl AccountManager {
/// The private-account count that every privacy-preserving transaction is padded up to with
/// dummy inputs via the default interface.
///
/// The value is selected based on the largest account number per-tx currently supported
/// (it is 7 for AMM). It is recommended to reassess this value per new actively supported
/// application and that all users share the value for a larger anonymity set.
const MAX_PRIVATE_ACCOUNTS: usize = 7;
pub async fn new(
wallet: &WalletCore,
accounts: Vec<AccountIdentity>,
@@ -257,8 +267,7 @@ impl AccountManager {
} => {
let acc = lee_core::account::Account::default();
let auth_acc = AccountWithMetadata::new(acc, true, (&npk, &vpk, identifier));
let mut random_seed: [u8; 32] = [0; 32];
OsRng.fill_bytes(&mut random_seed);
let random_seed = random_bytes();
let pre = AccountPreparedData {
nsk: None,
npk,
@@ -284,8 +293,7 @@ impl AccountManager {
} => {
let acc = lee_core::account::Account::default();
let auth_acc = AccountWithMetadata::new(acc, false, account_id);
let mut random_seed: [u8; 32] = [0; 32];
OsRng.fill_bytes(&mut random_seed);
let random_seed = random_bytes();
let pre = AccountPreparedData {
nsk: None,
npk,
@@ -391,6 +399,37 @@ impl AccountManager {
.collect()
}
/// Given a count, generate that many dummy inputs with randomized seeds and notes.
/// Uses the given commitment root from the account.
pub fn dummy_inputs(&self, count: usize) -> Vec<DummyInput> {
std::iter::repeat_with(|| DummyInput {
nullifier_seed: random_bytes(),
commitment_seed: random_bytes(),
note: random_dummy_note(),
commitment_root: self.dummy_commitment_root,
})
.take(count)
.collect()
}
/// Generate the dummy inputs that pad this transaction's private-account count up to
/// `MAX_PRIVATE_ACCOUNTS`.
pub fn dummy_inputs_default(&self) -> Vec<DummyInput> {
let private_count = self
.states
.iter()
.filter(|state| matches!(state, State::Private(_)))
.count();
if private_count > Self::MAX_PRIVATE_ACCOUNTS {
log::warn!(
"private account count {private_count} exceeds MAX_PRIVATE_ACCOUNTS ({}); \
padding saturates and the private-input count is not hidden",
Self::MAX_PRIVATE_ACCOUNTS
);
}
self.dummy_inputs(Self::MAX_PRIVATE_ACCOUNTS.saturating_sub(private_count))
}
/// Build the per-account input vec for the privacy-preserving circuit. Each variant carries
/// exactly the fields the circuit's code path for that account needs, with the ephemeral
/// keys (`ssk`) drawn from the cached values that `private_account_keys` and the message
@@ -536,8 +575,7 @@ fn private_key_tree_acc_preparation(
// support from that in the wallet.
let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), true, account_id);
let mut random_seed: [u8; 32] = [0; 32];
OsRng.fill_bytes(&mut random_seed);
let random_seed = random_bytes();
Ok(AccountPreparedData {
nsk: Some(nsk),
@@ -569,8 +607,7 @@ fn private_shared_acc_preparation(
let pre_state = AccountWithMetadata::new(acc, true, account_id);
let mut random_seed: [u8; 32] = [0; 32];
OsRng.fill_bytes(&mut random_seed);
let random_seed = random_bytes();
AccountPreparedData {
nsk: Some(nsk),
@@ -646,6 +683,34 @@ fn random_view_tag() -> ViewTag {
byte[0]
}
fn random_bytes() -> [u8; 32] {
let mut bytes = [0; 32];
OsRng.fill_bytes(&mut bytes);
bytes
}
fn random_vec(len: usize) -> Vec<u8> {
let mut bytes = vec![0; len];
OsRng.fill_bytes(&mut bytes);
bytes
}
/// Generates a dummy note: random bytes sized to a default-account ciphertext, a real
/// ML-KEM ciphertext epk toward a throwaway key, and a random view tag.
fn random_dummy_note() -> EncryptedAccountData {
// Sized to a default-account ciphertext; matching real data sizes is a separate issue.
let ciphertext_len = PrivateAccountKind::HEADER_LEN
.checked_add(Account::default().to_bytes().len())
.expect("dummy ciphertext length fits in usize");
let throwaway_ek = MlKem768EncapsulationKey::from_seed(&random_bytes(), &random_bytes());
let (_, epk) = SharedSecretKey::encapsulate(&throwaway_ek);
EncryptedAccountData {
ciphertext: Ciphertext::from_inner(random_vec(ciphertext_len)),
epk,
view_tag: random_view_tag(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -661,4 +726,66 @@ mod tests {
assert!(acc.is_private());
assert!(!acc.is_public());
}
fn private_state() -> State {
let npk = NullifierPublicKey([0; 32]);
let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]);
let pre_state = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0));
State::Private(AccountPreparedData {
nsk: None,
npk,
identifier: 0,
vpk,
pre_state,
proof: None,
random_seed: [0; 32],
is_pda: false,
})
}
fn public_state() -> State {
let npk = NullifierPublicKey([0; 32]);
let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]);
let account = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0));
State::Public { account, sk: None }
}
fn manager(states: Vec<State>) -> AccountManager {
AccountManager {
states,
pin: None,
dummy_commitment_root: [0; 32],
}
}
#[test]
fn dummy_inputs_default_pads_private_count_to_max() {
let max = AccountManager::MAX_PRIVATE_ACCOUNTS;
// Empty txs get padded to the max.
assert_eq!(manager(vec![]).dummy_inputs_default().len(), max);
// In a padded transaction, the padding amount depends on
// the amount of private accounts used.
assert_eq!(
manager(vec![private_state(), private_state()])
.dummy_inputs_default()
.len(),
max - 2
);
assert_eq!(
manager(vec![private_state(), public_state(), private_state()])
.dummy_inputs_default()
.len(),
max - 2
);
// If the private accounts in the transaction exceed the max, no padding
// is done.
let full: Vec<State> = std::iter::repeat_with(private_state).take(max).collect();
assert_eq!(manager(full).dummy_inputs_default().len(), 0);
let over: Vec<State> = std::iter::repeat_with(private_state)
.take(max + 2)
.collect();
assert_eq!(manager(over).dummy_inputs_default().len(), 0);
}
}
+32 -2
View File
@@ -19,12 +19,14 @@ use crate::{
config::ConfigSubcommand,
group::GroupSubcommand,
keycard::KeycardSubcommand,
network::NetworkAlias,
programs::{
amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand, bridge::BridgeSubcommand,
native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand,
token::TokenProgramAgnosticSubcommand, vault::VaultSubcommand,
},
},
config::SequencerConnectionData,
storage::Storage,
};
@@ -33,6 +35,7 @@ pub mod chain;
pub mod config;
pub mod group;
pub mod keycard;
pub mod network;
pub mod programs;
pub(crate) trait WalletSubcommand {
@@ -80,6 +83,11 @@ pub enum Command {
/// Command to setup config, get and set config fields.
#[command(subcommand)]
Config(ConfigSubcommand),
/// Change the network the wallet points to.
ChangeNetwork {
/// `testnet`, `local`, or a custom sequencer URL.
network: NetworkAlias,
},
/// Restoring keys from given password at given `depth`.
///
/// !!!WARNING!!! will rewrite current storage.
@@ -159,14 +167,22 @@ impl CliAccountMention {
}
}
/// Convert to an [`crate::AccountIdentity`] for use in a public transaction.
///
/// The `sign` flag indicates whether to sign or not with the account keys.
#[must_use]
pub fn into_public_identity(self, account_id: lee::AccountId) -> crate::AccountIdentity {
pub fn into_public_identity(
self,
account_id: lee::AccountId,
sign: bool,
) -> crate::AccountIdentity {
match self {
Self::KeyPath(key_path) => crate::AccountIdentity::PublicKeycard {
account_id,
key_path,
},
Self::Id(_) | Self::Label(_) => crate::AccountIdentity::Public(account_id),
Self::Id(_) | Self::Label(_) if sign => crate::AccountIdentity::Public(account_id),
Self::Id(_) | Self::Label(_) => crate::AccountIdentity::PublicNoSign(account_id),
}
}
}
@@ -267,6 +283,20 @@ pub async fn execute_subcommand(
Command::Config(config_subcommand) => {
config_subcommand.handle_subcommand(wallet_core).await?
}
Command::ChangeNetwork { network } => {
let sequencer_addr: url::Url = network.try_into().context("Invalid sequencer URL")?;
let mut config = wallet_core.config().clone();
config.sequencers = vec![SequencerConnectionData {
sequencer_addr,
basic_auth: None,
}];
wallet_core.set_config(config);
wallet_core.store_config_changes().await?;
SubcommandReturnValue::Empty
}
Command::RestoreKeys { depth } => {
let mnemonic = read_mnemonic_from_stdin()?;
let password = read_password_from_stdin()?;
+38
View File
@@ -0,0 +1,38 @@
use std::{convert::Infallible, str::FromStr};
use url::Url;
const TESTNET_SEQUENCER_ADDR: &str = "https://testnet.lez.logos.co";
const LOCAL_SEQUENCER_ADDR: &str = "http://127.0.0.1:3040";
/// A `change-network` argument: `testnet`, `local`, or a custom sequencer URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NetworkAlias {
Testnet,
Local,
Other(String),
}
impl FromStr for NetworkAlias {
type Err = Infallible;
fn from_str(network: &str) -> Result<Self, Self::Err> {
Ok(match network {
"testnet" => Self::Testnet,
"local" => Self::Local,
other => Self::Other(other.to_owned()),
})
}
}
impl TryFrom<NetworkAlias> for Url {
type Error = url::ParseError;
fn try_from(alias: NetworkAlias) -> Result<Self, Self::Error> {
match alias {
NetworkAlias::Testnet => TESTNET_SEQUENCER_ADDR.parse(),
NetworkAlias::Local => LOCAL_SEQUENCER_ADDR.parse(),
NetworkAlias::Other(url) => url.parse(),
}
}
}
+11 -11
View File
@@ -138,9 +138,9 @@ impl AmmProgramAgnosticSubcommand {
) => {
let tx_hash = Amm(wallet_core)
.send_new_definition(
user_holding_a.into_public_identity(a),
user_holding_b.into_public_identity(b),
user_holding_lp.into_public_identity(lp),
user_holding_a.into_public_identity(a, true),
user_holding_b.into_public_identity(b, true),
user_holding_lp.into_public_identity(lp, true),
balance_a,
balance_b,
)
@@ -170,8 +170,8 @@ impl AmmProgramAgnosticSubcommand {
(AccountIdWithPrivacy::Public(a), AccountIdWithPrivacy::Public(b)) => {
let tx_hash = Amm(wallet_core)
.send_swap_exact_input(
user_holding_a.into_public_identity(a),
user_holding_b.into_public_identity(b),
user_holding_a.into_public_identity(a, true),
user_holding_b.into_public_identity(b, true),
amount_in,
min_amount_out,
token_definition,
@@ -202,8 +202,8 @@ impl AmmProgramAgnosticSubcommand {
(AccountIdWithPrivacy::Public(a), AccountIdWithPrivacy::Public(b)) => {
let tx_hash = Amm(wallet_core)
.send_swap_exact_output(
user_holding_a.into_public_identity(a),
user_holding_b.into_public_identity(b),
user_holding_a.into_public_identity(a, true),
user_holding_b.into_public_identity(b, true),
exact_amount_out,
max_amount_in,
token_definition,
@@ -240,9 +240,9 @@ impl AmmProgramAgnosticSubcommand {
) => {
let tx_hash = Amm(wallet_core)
.send_add_liquidity(
user_holding_a.into_public_identity(a),
user_holding_b.into_public_identity(b),
user_holding_lp.into_public_identity(lp),
user_holding_a.into_public_identity(a, true),
user_holding_b.into_public_identity(b, true),
user_holding_lp.into_public_identity(lp, true),
min_amount_lp,
max_amount_a,
max_amount_b,
@@ -281,7 +281,7 @@ impl AmmProgramAgnosticSubcommand {
.send_remove_liquidity(
a,
b,
user_holding_lp.into_public_identity(lp),
user_holding_lp.into_public_identity(lp, true),
balance_lp,
min_amount_a,
min_amount_b,
+3 -3
View File
@@ -94,7 +94,7 @@ impl AtaSubcommand {
match owner_resolved {
AccountIdWithPrivacy::Public(owner_id) => {
let tx_hash = Ata(wallet_core)
.send_create(owner.into_public_identity(owner_id), definition_id)
.send_create(owner.into_public_identity(owner_id, true), definition_id)
.await?;
wallet_core
.poll_and_finalize_public_transaction(tx_hash)
@@ -127,7 +127,7 @@ impl AtaSubcommand {
AccountIdWithPrivacy::Public(from_id) => {
let tx_hash = Ata(wallet_core)
.send_transfer(
from.into_public_identity(from_id),
from.into_public_identity(from_id, true),
definition_id,
to_id,
amount,
@@ -162,7 +162,7 @@ impl AtaSubcommand {
AccountIdWithPrivacy::Public(holder_id) => {
let tx_hash = Ata(wallet_core)
.send_burn(
holder.into_public_identity(holder_id),
holder.into_public_identity(holder_id, true),
definition_id,
amount,
)
@@ -61,7 +61,7 @@ impl AuthTransferSubcommand {
match resolved {
AccountIdWithPrivacy::Public(pub_account_id) => {
let tx_hash = NativeTokenTransfer(wallet_core)
.register_account(account_id.into_public_identity(pub_account_id))
.register_account(account_id.into_public_identity(pub_account_id, true))
.await?;
wallet_core
@@ -123,8 +123,8 @@ impl AuthTransferSubcommand {
(AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Public(to)) => {
let to_mention = to_account.expect("matched Some branch");
NativeTokenTransferProgramSubcommand::Public {
from: Some(from_account.into_public_identity(from)),
to: Some(to_mention.into_public_identity(to)),
from: Some(from_account.into_public_identity(from, true)),
to: Some(to_mention.into_public_identity(to, false)),
amount,
}
}
@@ -143,7 +143,7 @@ impl AuthTransferSubcommand {
(AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Private(to)) => {
NativeTokenTransferProgramSubcommand::Shielded(
NativeTokenTransferProgramSubcommandShielded::ShieldedOwned {
from: Some(from_account.into_public_identity(from)),
from: Some(from_account.into_public_identity(from, true)),
to,
amount,
},
@@ -165,7 +165,7 @@ impl AuthTransferSubcommand {
AccountIdWithPrivacy::Public(from) => {
NativeTokenTransferProgramSubcommand::Shielded(
NativeTokenTransferProgramSubcommandShielded::ShieldedForeign {
from: Some(from_account.into_public_identity(from)),
from: Some(from_account.into_public_identity(from, true)),
to_npk,
to_vpk,
to_identifier,
+9 -9
View File
@@ -208,7 +208,7 @@ impl TokenProgramAgnosticSubcommand {
(AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Private(to)) => {
TokenProgramSubcommand::Shielded(
TokenProgramSubcommandShielded::TransferTokenShieldedOwned {
sender: Some(from_mention.into_public_identity(from)),
sender: Some(from_mention.into_public_identity(from, true)),
recipient_account_id: to,
balance_to_move: amount,
},
@@ -237,7 +237,7 @@ impl TokenProgramAgnosticSubcommand {
),
AccountIdWithPrivacy::Public(from) => TokenProgramSubcommand::Shielded(
TokenProgramSubcommandShielded::TransferTokenShieldedForeign {
sender: Some(from_mention.into_public_identity(from)),
sender: Some(from_mention.into_public_identity(from, true)),
recipient_npk: to_npk,
recipient_vpk: to_vpk,
recipient_identifier: to_identifier,
@@ -830,8 +830,8 @@ impl TokenProgramSubcommandPublic {
};
let tx_hash = Token(wallet_core)
.send_transfer_transaction(
sender_account_id.into_public_identity(sender_id),
recipient_account_id.into_public_identity(recipient_id),
sender_account_id.into_public_identity(sender_id, true),
recipient_account_id.into_public_identity(recipient_id, false),
balance_to_move,
)
.await?;
@@ -855,7 +855,7 @@ impl TokenProgramSubcommandPublic {
let tx_hash = Token(wallet_core)
.send_burn_transaction(
definition_account_id,
holder_account_id.into_public_identity(holder_id),
holder_account_id.into_public_identity(holder_id, true),
amount,
)
.await?;
@@ -881,8 +881,8 @@ impl TokenProgramSubcommandPublic {
};
let tx_hash = Token(wallet_core)
.send_mint_transaction(
definition_account_id.into_public_identity(def_id),
holder_account_id.into_public_identity(holder_id),
definition_account_id.into_public_identity(def_id, true),
holder_account_id.into_public_identity(holder_id, false),
amount,
)
.await?;
@@ -1546,8 +1546,8 @@ impl CreateNewTokenProgramSubcommand {
};
let tx_hash = Token(wallet_core)
.send_new_definition(
definition_account_id.into_public_identity(def_id),
supply_account_id.into_public_identity(sup_id),
definition_account_id.into_public_identity(def_id, true),
supply_account_id.into_public_identity(sup_id, true),
name,
total_supply,
)
+1 -1
View File
@@ -77,7 +77,7 @@ impl Default for WalletConfig {
fn default() -> Self {
Self {
sequencers: vec![SequencerConnectionData {
sequencer_addr: "http://127.0.0.1:3040".parse().unwrap(),
sequencer_addr: "https://testnet.lez.logos.co".parse().unwrap(),
basic_auth: None,
}],
seq_poll_timeout: Duration::from_secs(12),
+8 -6
View File
@@ -776,12 +776,14 @@ impl WalletCore {
)?;
let private_account_keys = acc_manager.private_account_keys();
let (output, proof) = lee::privacy_preserving_transaction::circuit::execute_and_prove(
pre_states,
instruction_data,
acc_manager.account_identities(),
&program.to_owned(),
)?;
let (output, proof) =
lee::privacy_preserving_transaction::circuit::execute_and_prove_with_padded_inputs(
pre_states,
instruction_data,
acc_manager.account_identities(),
acc_manager.dummy_inputs_default(),
&program.to_owned(),
)?;
let message =
lee::privacy_preserving_transaction::message::Message::try_from_circuit_output(
+31 -33
View File
@@ -11,6 +11,7 @@ use std::{collections::HashMap, path::Path, sync::Arc};
use anyhow::{Context as _, Result};
use common::{HashType, transaction::LeeTransaction};
use itertools::Itertools as _;
use lee_core::BlockId;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use serde::{Deserialize, Serialize};
@@ -97,6 +98,14 @@ impl MultiSequencerClient {
statistics: &mut HashMap<Url, Statistics>,
multi_sequencer_client_config: &MultiSequencerClientConfig,
) -> Result<Vec<(SequencerClient, Url)>> {
if !conn_data
.iter()
.map(|conn| &conn.sequencer_addr)
.all_unique()
{
anyhow::bail!("All addresses must be unique");
}
let mut actualization_list = vec![];
let mut calibration_list = vec![];
let mut client_list = vec![];
@@ -133,7 +142,7 @@ impl MultiSequencerClient {
client_list.push((sequencer_addr.clone(), sequencer_client));
}
// This actually runs in one thread, but eah exact future is parallelized.
// This actually starts in one thread, but each exact future produces more tasks.
let (actualization_res, callibration_res) = tokio::join!(
multi_actualize_clients(actualization_list),
multi_calibrate_clients(
@@ -300,7 +309,7 @@ impl MultiSequencerClient {
/// Metered `send_transaction` for `distribution_limit` amount of leaders.
///
/// Less abstract that it could be, clean way to implement it in a more general way is
/// Less abstract than it could be, clean way to implement it in a more general way is
/// "return-type notation".
///
/// `ToDo`: Return to it, when "return-type notation" is stable.
@@ -332,6 +341,7 @@ impl MultiSequencerClient {
while let Some(resp) = join_set.join_next().await {
let res = resp
.inspect_err(|j_err| log::warn!("Task failed with join error: {j_err:?}"))
.map_err(Into::into)
.and_then(|((resp, statistics_update), leader_url)| {
log::debug!(
@@ -524,7 +534,7 @@ async fn actualize_client(client: SequencerClient) -> StatisticsUpdate {
// Next 2 functions can be done in uniform way right now, but it will be incredibly cursed.
// ToDo: Return to it when "return-type notation" is stable
pub async fn multi_actualize_clients(
async fn multi_actualize_clients(
clients: Vec<(Url, SequencerClient)>,
) -> Vec<(Url, Option<StatisticsUpdate>)> {
let mut handle_map = HashMap::new();
@@ -550,7 +560,7 @@ pub async fn multi_actualize_clients(
statistic_updates
}
pub async fn multi_calibrate_clients(
async fn multi_calibrate_clients(
clients: Vec<(Url, SequencerClient)>,
calibration_limit: usize,
) -> Vec<(Url, Option<Statistics>)> {
@@ -588,10 +598,10 @@ pub async fn multi_calibrate_clients(
statistics
}
#[must_use]
/// Choosing leaders up to a `distribution_limit`.
///
/// Assumes that all clients have their statistics in `statistics`.
#[must_use]
fn choose_leaders(
client_vec: Vec<(Url, SequencerClient)>,
statistics: &HashMap<Url, Statistics>,
@@ -627,7 +637,7 @@ fn choose_leaders(
// Sort out all clients running late
let mut res_vec: Vec<_> = client_vec
.iter()
.into_iter()
.filter(|x| {
let latest_block_id = statistics.get(&x.0).unwrap().latest_block_id;
@@ -659,16 +669,17 @@ fn choose_leaders(
.expect("Ratios must be a valid numbers")
});
res_vec = res_vec[..(res_vec
.iter()
.position(|item| {
error_ratio(
statistics.get(&item.0).unwrap().errors,
statistics.get(&item.0).unwrap().sample_size,
) > avg_err_ratio
})
.unwrap_or(res_vec.len()))]
.to_vec();
res_vec.truncate(
res_vec
.iter()
.position(|item| {
error_ratio(
statistics.get(&item.0).unwrap().errors,
statistics.get(&item.0).unwrap().sample_size,
) > avg_err_ratio
})
.unwrap_or(res_vec.len()),
);
// Choose clients with least latency and variance
res_vec.sort_by(|a, b| {
@@ -680,28 +691,15 @@ fn choose_leaders(
let right_std = right_var.sqrt();
let left_std = left_var.sqrt();
// Client is better if its average is better and variance does not make it worse
// So basically we want this:
// [-right_std < left_lat < right_lat < +left_std < +right_std]
//
// However one can argue that this:
//
// [-right_std < right_lat < left_lat < +left_std < +right_std]
//
// is still better, but it is up to discussion
let first_ordering = left_lat.total_cmp(&right_lat);
match first_ordering {
std::cmp::Ordering::Greater => first_ordering,
std::cmp::Ordering::Less | std::cmp::Ordering::Equal => {
(left_lat + left_std).total_cmp(&(right_lat + right_std))
}
}
// Client is better if its average + std is lesser:
// [-right_std < right_lat < +left_std < +right_std]
(left_lat + left_std).total_cmp(&(right_lat + right_std))
});
Some(
res_vec
.into_iter()
.map(|(a, b)| (b.clone(), a.clone()))
.map(|(a, b)| (b, a))
.take(distribution_limit)
.collect(),
)
@@ -19,7 +19,7 @@ impl NativeTokenTransfer<'_> {
self.0
.resolve_private_account(from)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
AccountIdentity::Public(to),
AccountIdentity::PublicNoSign(to),
],
instruction_data,
&program.into(),
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "bridge_lock_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
lee = { workspace = true, optional = true }
[features]
# Host-only genesis helper; pulls `lee`, so the risc0 guest builds without it.
host = ["dep:lee"]
-92
View File
@@ -1,92 +0,0 @@
//! Core types for the bridge-lock program, the source side of the cross-zone
//! token bridge. A holder locks part of their balance into an escrow and emits a
//! cross-zone message minting the wrapped token on the target zone.
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Lock `amount` of the holder's balance and emit a cross-zone message
/// minting the wrapped token on `target_zone`. The emission fields mirror
/// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly.
///
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
Lock {
amount: u128,
target_zone: [u8; 32],
target_program_id: ProgramId,
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
outbox_program_id: ProgramId,
ordinal: u32,
},
}
/// PDA accumulating all locked balance on this zone.
#[must_use]
pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&bridge_lock_id, &escrow_seed())
}
#[must_use]
pub fn escrow_seed() -> PdaSeed {
PdaSeed::new(ESCROW_SEED_DOMAIN)
}
/// Reads a bridgeable balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
/// Builds the genesis holding account funding a holder's bridgeable balance:
/// owned by bridge_lock, data is the LE balance, at the holder's account id. It
/// is not produced by any transaction, so the sequencer and the indexer both
/// seed it through this one builder to keep their genesis states identical.
#[cfg(feature = "host")]
#[must_use]
pub fn build_holding_account(
holder: AccountId,
amount: u128,
) -> (AccountId, lee_core::account::Account) {
let account = lee_core::account::Account {
program_owner: lee::program::Program::bridge_lock().id(),
data: balance_bytes(amount)
.to_vec()
.try_into()
.expect("balance fits in account data"),
..Default::default()
};
(holder, account)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(7)), 7);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn escrow_is_stable() {
let id: ProgramId = [4; 8];
assert_eq!(escrow_account_id(id), escrow_account_id(id));
}
}
-22
View File
@@ -1,22 +0,0 @@
[package]
name = "cross_zone_inbox_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
risc0-zkvm.workspace = true
borsh.workspace = true
lee = { workspace = true, optional = true }
ping_core = { workspace = true, optional = true }
bridge_lock_core = { workspace = true, optional = true }
[features]
# Host-only transaction builder and emission extractor; pull `lee` and the
# emitter cores, so the risc0 guest builds without them.
host = ["dep:lee", "dep:ping_core", "dep:bridge_lock_core"]
-378
View File
@@ -1,378 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
pub type ZoneId = [u8; 32];
/// Block-signing public key pinned per peer zone.
pub type ExpectedPubkey = [u8; 32];
/// Content-addressed replay key for a delivered message.
pub type MessageKey = [u8; 32];
/// Source blocks per seen-set shard, so no single seen account grows without bound.
pub const EPOCH_BLOCKS: u64 = 10_000;
const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/";
const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/";
const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/";
/// A peer zone whose outbox a zone watches for inbound cross-zone messages.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrossZonePeer {
/// The peer's Bedrock channel; its 32 bytes double as the peer's zone id.
pub channel_id: ZoneId,
/// Programs on the local zone a message from this peer is allowed to target.
pub allowed_targets: Vec<ProgramId>,
/// The peer's block-signing public key, pinned to reject blocks inscribed by
/// anyone other than that zone's sequencer. `None` skips the check (the
/// channel signer is still authenticated by the zone-sdk).
#[serde(default)]
pub expected_block_signing_pubkey: Option<[u8; 32]>,
}
/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer
/// (verifier): the peers it reads from Bedrock and, per peer, the local programs
/// they may deliver to.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrossZoneConfig {
pub peers: Vec<CrossZonePeer>,
}
/// A finalized outbound message observed on a peer zone, addressed to a program
/// on this zone. The watcher fills it from the peer's block; it is never
/// self-reported by a user.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrossZoneMessage {
pub src_zone: ZoneId,
pub src_block_id: u64,
pub src_tx_index: u32,
pub src_program_id: ProgramId,
pub target_program_id: ProgramId,
pub payload: Vec<u8>,
/// Reserved for a future source-state proof; MUST be `None` in v1.
pub l1_inclusion_witness: Option<Vec<u8>>,
}
/// Peer and per-peer target allowlists, plus this inbox's own zone id.
#[derive(
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize,
)]
pub struct InboxConfig {
pub self_zone: ZoneId,
pub allowed_peers: BTreeMap<ZoneId, ExpectedPubkey>,
pub allowed_targets: BTreeMap<ZoneId, Vec<ProgramId>>,
}
impl InboxConfig {
/// Borsh-encoded form stored in the inbox config account.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("InboxConfig serializes")
}
/// Decodes an [`InboxConfig`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
borsh::from_slice(bytes)
}
}
/// The replay keys seen for one `(src_zone, epoch)` shard.
#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct SeenShard(pub BTreeSet<MessageKey>);
impl SeenShard {
/// Decodes a shard from account data; empty data is an empty shard.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
if bytes.is_empty() {
return Ok(Self::default());
}
borsh::from_slice(bytes)
}
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("SeenShard serializes")
}
#[must_use]
pub fn contains(&self, key: &MessageKey) -> bool {
self.0.contains(key)
}
/// Inserts a key; returns true if it was newly inserted.
pub fn insert(&mut self, key: MessageKey) -> bool {
self.0.insert(key)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Delivers a finalized peer message to its target program.
Dispatch(CrossZoneMessage),
}
/// Content-addressed replay key: `(src_zone, src_block_id, src_tx_index)` hashed
/// under a domain separator. Watcher-independent and immune to proof
/// malleability, since it keys on block id plus index rather than a tx hash.
#[must_use]
pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::with_capacity(MESSAGE_KEY_DOMAIN.len() + 32 + 8 + 4);
bytes.extend_from_slice(&MESSAGE_KEY_DOMAIN);
bytes.extend_from_slice(src_zone);
bytes.extend_from_slice(&src_block_id.to_le_bytes());
bytes.extend_from_slice(&src_tx_index.to_le_bytes());
Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!())
}
/// The config account holding the allowlists.
#[must_use]
pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED))
}
/// The seen-set shard for the `(src_zone, epoch)` the message falls in.
#[must_use]
pub fn inbox_seen_shard_account_id(
inbox_id: ProgramId,
src_zone: &ZoneId,
src_block_id: u64,
) -> AccountId {
AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id))
}
/// Seed of the seen-shard PDA, exposed so the guest can claim the account.
#[must_use]
pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let src_epoch = src_block_id / EPOCH_BLOCKS;
let mut bytes = Vec::with_capacity(INBOX_SEEN_SEED_DOMAIN.len() + 32 + 8);
bytes.extend_from_slice(&INBOX_SEEN_SEED_DOMAIN);
bytes.extend_from_slice(src_zone);
bytes.extend_from_slice(&src_epoch.to_le_bytes());
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
/// Builds the sequencer-origin dispatch transaction. Pure, so the watcher's
/// injected tx and the indexer's re-derived tx are byte-identical for the same
/// inputs (the basis of the Option B check). `target_account_ids` are the
/// inbox's chained-call targets; deriving them is target-specific.
#[cfg(feature = "host")]
#[must_use]
pub fn build_inbox_dispatch_tx(
inbox_id: ProgramId,
msg: &CrossZoneMessage,
target_account_ids: Vec<AccountId>,
) -> lee::PublicTransaction {
let mut account_ids = Vec::with_capacity(2 + target_account_ids.len());
account_ids.push(inbox_config_account_id(inbox_id));
account_ids.push(inbox_seen_shard_account_id(
inbox_id,
&msg.src_zone,
msg.src_block_id,
));
account_ids.extend(target_account_ids);
let message = lee::public_transaction::Message::try_new(
inbox_id,
account_ids,
vec![],
Instruction::Dispatch(msg.clone()),
)
.expect("inbox dispatch instruction must serialize");
lee::PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}
/// The cross-zone emission fields a watcher or verifier reads off a source
/// transaction, common to every emitter program.
#[cfg(feature = "host")]
pub struct Emission {
pub target_zone: ZoneId,
pub target_program_id: ProgramId,
pub target_accounts: Vec<[u8; 32]>,
pub payload: Vec<u8>,
}
/// Extracts the cross-zone emission from a source transaction, recognizing the
/// known emitter programs. Returns `None` for any other program. The watcher and
/// verifier both use this so they agree on what a given source tx emits.
///
/// Option A: each emitter is decoded explicitly. The principled alternative is to
/// read the outbox PDA write, which would need re-execution of the source tx.
#[cfg(feature = "host")]
#[must_use]
pub fn extract_emission(
program_id: ProgramId,
instruction_data: &[u32],
ping_sender_id: ProgramId,
bridge_lock_id: ProgramId,
) -> Option<Emission> {
if program_id == ping_sender_id {
let ping_core::SenderInstruction::Send {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else if program_id == bridge_lock_id {
let bridge_lock_core::Instruction::Lock {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else {
None
}
}
/// Builds the dispatch transaction for one peer emission. Both the sequencer's
/// watcher and the indexer's verifier go through this so their transactions are
/// byte-identical for the same emission (the basis of the Option B check).
#[cfg(feature = "host")]
#[must_use]
pub fn build_dispatch_from_emission(
inbox_id: ProgramId,
src_zone: ZoneId,
src_block_id: u64,
src_tx_index: u32,
src_program_id: ProgramId,
target_program_id: ProgramId,
target_accounts: &[[u8; 32]],
payload: Vec<u8>,
) -> lee::PublicTransaction {
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index,
src_program_id,
target_program_id,
payload,
l1_inclusion_witness: None,
};
let target_ids = target_accounts
.iter()
.copied()
.map(AccountId::new)
.collect();
build_inbox_dispatch_tx(inbox_id, &msg, target_ids)
}
/// Builds the inbox config account a zone seeds into genesis state so the inbox
/// guest can authorize inbound peer messages. The sequencer and indexer seed the
/// same account from the same config, keeping their replayed state consistent.
#[cfg(feature = "host")]
#[must_use]
pub fn build_inbox_config_account(
self_zone: ZoneId,
cross_zone: &CrossZoneConfig,
) -> (AccountId, lee_core::account::Account) {
let inbox_id = lee::program::Program::cross_zone_inbox().id();
let mut allowed_targets = BTreeMap::new();
for peer in &cross_zone.peers {
allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone());
}
let config = InboxConfig {
self_zone,
allowed_peers: BTreeMap::new(),
allowed_targets,
};
let account = lee_core::account::Account {
program_owner: inbox_id,
balance: 0,
data: config
.to_bytes()
.try_into()
.expect("inbox config fits in account data"),
nonce: 0_u128.into(),
};
(inbox_config_account_id(inbox_id), account)
}
#[cfg(test)]
mod tests {
use super::*;
fn zone(b: u8) -> ZoneId {
[b; 32]
}
#[test]
fn message_key_is_stable_and_content_addressed() {
assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4));
}
#[test]
fn seen_shards_split_on_epoch_boundary() {
let id: ProgramId = [9; 8];
assert_eq!(
inbox_seen_shard_account_id(id, &zone(1), 0),
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
);
assert_ne!(
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS),
);
}
#[cfg(feature = "host")]
#[test]
fn build_inbox_dispatch_tx_is_deterministic() {
let inbox: ProgramId = [5; 8];
let msg = CrossZoneMessage {
src_zone: zone(1),
src_block_id: 42,
src_tx_index: 2,
src_program_id: [6; 8],
target_program_id: [7; 8],
payload: vec![1, 2, 3, 4],
l1_inclusion_witness: None,
};
let targets = vec![AccountId::new([8; 32]), AccountId::new([9; 32])];
let tx1 = build_inbox_dispatch_tx(inbox, &msg, targets.clone());
let tx2 = build_inbox_dispatch_tx(inbox, &msg, targets);
assert_eq!(tx1, tx2);
}
}
@@ -1,14 +0,0 @@
[package]
name = "cross_zone_outbox_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
risc0-zkvm.workspace = true
borsh.workspace = true
@@ -1,93 +0,0 @@
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
pub type ZoneId = [u8; 32];
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Records an outbound cross-zone message as a write to a self-owned PDA.
///
/// Required accounts (1):
/// - Outbox PDA account
Emit {
target_zone: ZoneId,
target_program_id: ProgramId,
/// Accounts the destination inbox must hand to the target program's
/// chained call. The emitter specifies them; the watcher forwards them
/// verbatim so the inbox stays target-agnostic.
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
ordinal: u32,
},
}
/// The message as stored in an outbox PDA. The destination zone's watcher reads
/// this from the inscribed block; the source coordinates are filled by the
/// watcher, not stored here.
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct OutboxRecord {
pub target_zone: ZoneId,
pub target_program_id: ProgramId,
pub target_accounts: Vec<[u8; 32]>,
pub payload: Vec<u8>,
}
impl OutboxRecord {
/// Borsh-encoded form stored in the outbox PDA's account data.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("OutboxRecord serializes")
}
/// Decodes an [`OutboxRecord`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
borsh::from_slice(bytes)
}
}
/// PDA holding one emitted message, keyed by destination zone and a per-zone
/// ordinal.
#[must_use]
pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId {
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal))
}
/// Seed of an outbox message PDA, exposed so the guest can claim the account.
#[must_use]
pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::with_capacity(OUTBOX_SEED_DOMAIN.len() + target_zone.len() + 4);
bytes.extend_from_slice(&OUTBOX_SEED_DOMAIN);
bytes.extend_from_slice(target_zone);
bytes.extend_from_slice(&ordinal.to_le_bytes());
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outbox_pda_is_unique_per_zone_and_ordinal() {
let id: ProgramId = [3; 8];
let zone_a = [1; 32];
let zone_b = [2; 32];
assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0));
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1));
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0));
}
}
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "ping_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
-38
View File
@@ -1,38 +0,0 @@
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/";
/// Instruction delivered to `ping_receiver` by the inbox: record the payload.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReceiverInstruction {
Record { payload: Vec<u8> },
}
/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SenderInstruction {
Send {
outbox_program_id: ProgramId,
target_zone: [u8; 32],
target_program_id: ProgramId,
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
ordinal: u32,
},
}
/// The account a `ping_receiver` records the latest delivered payload into.
#[must_use]
pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&receiver_id, &ping_record_seed())
}
/// Seed of the record PDA, exposed so the guest can claim the account.
#[must_use]
pub fn ping_record_seed() -> PdaSeed {
PdaSeed::new(PING_RECORD_SEED)
}
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "wrapped_token_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
risc0-zkvm.workspace = true
-121
View File
@@ -1,121 +0,0 @@
//! Core types for the wrapped-token program, the destination side of the
//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces
//! this by reading the authorized minter from a genesis-seeded config account.
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/";
const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by
/// the cross-zone inbox.
///
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
/// holding PDA.
Mint { recipient: [u8; 32], amount: u128 },
}
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at
/// genesis so the guest can pin its caller without importing the inbox image id.
#[must_use]
pub fn config_account_id(wrapped_token_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&wrapped_token_id, &config_seed())
}
#[must_use]
pub fn config_seed() -> PdaSeed {
PdaSeed::new(CONFIG_SEED_DOMAIN)
}
/// PDA holding one recipient's wrapped-token balance.
#[must_use]
pub fn holding_account_id(wrapped_token_id: ProgramId, recipient: &[u8; 32]) -> AccountId {
AccountId::for_public_pda(&wrapped_token_id, &holding_seed(recipient))
}
#[must_use]
pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::with_capacity(HOLDING_SEED_DOMAIN.len() + recipient.len());
bytes.extend_from_slice(&HOLDING_SEED_DOMAIN);
bytes.extend_from_slice(recipient);
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
/// Encodes the authorized minter program id for the config account's data.
#[must_use]
pub fn minter_bytes(minter: ProgramId) -> [u8; 32] {
let mut bytes = [0_u8; 32];
for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) {
chunk.copy_from_slice(&word.to_le_bytes());
}
bytes
}
/// Decodes the authorized minter program id from the config account's data.
#[must_use]
pub fn read_minter(data: &[u8]) -> Option<ProgramId> {
if data.len() < 32 {
return None;
}
let mut minter = [0_u32; 8];
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
}
Some(minter)
}
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minter_round_trips() {
let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8];
assert_eq!(read_minter(&minter_bytes(minter)), Some(minter));
}
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(42)), 42);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn holding_is_unique_per_recipient() {
let id: ProgramId = [9; 8];
assert_ne!(
holding_account_id(id, &[1; 32]),
holding_account_id(id, &[2; 32])
);
assert_eq!(
holding_account_id(id, &[1; 32]),
holding_account_id(id, &[1; 32])
);
}
}
Binary file not shown.
+3 -1
View File
@@ -442,7 +442,9 @@ async fn poll_finality(state: Arc<AppState>) {
fn decode_inbox_text(instruction_data: &[u32]) -> Option<String> {
let instruction: Instruction =
risc0_zkvm::serde::from_slice::<Instruction, u32>(instruction_data).ok()?;
let Instruction::Dispatch(message) = instruction;
let Instruction::Dispatch(message) = instruction else {
return None;
};
decode_payload(&message.payload)
}