Merge 329f8c02042e29956d63434267af214e575ce63d into 006647bc8362fd5489d27906652a4a182fe1911d

This commit is contained in:
Daniil Polyakov 2026-05-22 22:10:23 +00:00 committed by GitHub
commit e078765918
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1156 additions and 136 deletions

125
Cargo.lock generated
View File

@ -1319,6 +1319,14 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "bridge_core"
version = "0.1.0"
dependencies = [
"nssa_core",
"serde",
]
[[package]]
name = "bs58"
version = "0.5.1"
@ -2847,6 +2855,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@ -2854,7 +2871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@ -2868,6 +2885,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@ -3638,6 +3661,22 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@ -3656,9 +3695,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"system-configuration 0.7.0",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -4071,6 +4112,8 @@ dependencies = [
"anyhow",
"ata_core",
"authenticated_transfer_core",
"borsh",
"bridge_core",
"bytesize",
"common",
"faucet_core",
@ -4080,8 +4123,11 @@ dependencies = [
"indexer_service_rpc",
"key_protocol",
"log",
"logos-blockchain-core",
"logos-blockchain-http-api-common",
"nssa",
"nssa_core",
"reqwest",
"sequencer_core",
"sequencer_service_rpc",
"serde_json",
@ -6114,7 +6160,7 @@ dependencies = [
"bitflags 2.11.0",
"block",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"log",
"objc",
"paste",
@ -6253,6 +6299,23 @@ dependencies = [
"unsigned-varint 0.7.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "natpmp"
version = "0.5.0"
@ -6460,6 +6523,7 @@ dependencies = [
"anyhow",
"authenticated_transfer_core",
"borsh",
"bridge_core",
"clock_core",
"env_logger",
"faucet_core",
@ -6718,12 +6782,49 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "opentelemetry"
version = "0.31.0"
@ -7241,6 +7342,7 @@ dependencies = [
"ata_core",
"ata_program",
"authenticated_transfer_core",
"bridge_core",
"clock_core",
"faucet_core",
"nssa_core",
@ -7820,6 +7922,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
@ -7828,9 +7931,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@ -7841,6 +7947,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@ -8717,6 +8824,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"borsh",
"bridge_core",
"bytesize",
"clap",
"common",
@ -8724,6 +8832,8 @@ dependencies = [
"futures",
"jsonrpsee",
"log",
"logos-blockchain-core",
"logos-blockchain-zone-sdk",
"mempool",
"nssa",
"sequencer_core",
@ -8731,6 +8841,7 @@ dependencies = [
"sequencer_service_rpc",
"tokio",
"tokio-util",
"vault_core",
]
[[package]]
@ -9700,6 +9811,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"

View File

@ -22,6 +22,7 @@ members = [
"programs/associated_token_account",
"programs/authenticated_transfer/core",
"programs/faucet/core",
"programs/bridge/core",
"programs/vault/core",
"sequencer/core",
"sequencer/service",
@ -75,6 +76,7 @@ ata_core = { path = "programs/associated_token_account/core" }
ata_program = { path = "programs/associated_token_account" }
authenticated_transfer_core = { path = "programs/authenticated_transfer/core" }
faucet_core = { path = "programs/faucet/core" }
bridge_core = { path = "programs/bridge/core" }
vault_core = { path = "programs/vault/core" }
test_program_methods = { path = "test_program_methods" }
testnet_initial_state = { path = "testnet_initial_state" }
@ -141,6 +143,7 @@ logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-block
logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
rocksdb = { version = "0.24.0", default-features = false, features = [
"snappy",

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.

View File

@ -67,7 +67,7 @@ impl NSSATransaction {
}
/// Validates the transaction against the current state and returns the resulting diff
/// without applying it. Rejects transactions that modify clock or faucet system accounts,
/// without applying it. Rejects transactions that modify clock, faucet or bridge accounts,
/// whether directly or indirectly via chain calls.
///
/// This check is required for all user transactions. Only sequencer transactions may bypass
@ -90,26 +90,12 @@ impl NSSATransaction {
}
}?;
let public_diff = diff.public_diff();
let touches_clock = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().any(|id| {
public_diff
.get(id)
.is_some_and(|post| *post != state.get_account_by_id(*id))
});
if touches_clock {
return Err(nssa::error::NssaError::InvalidInput(
"Transaction modifies system clock accounts".into(),
));
}
let faucet_id = nssa::system_faucet_account_id();
if public_diff
.get(&faucet_id)
.is_some_and(|post| *post != state.get_account_by_id(faucet_id))
{
return Err(nssa::error::NssaError::InvalidInput(
"Transaction modifies system faucet account".into(),
));
let system_accounts = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().copied().chain([
nssa::system_faucet_account_id(),
nssa::system_bridge_account_id(),
]);
for account_id in system_accounts {
validate_doesnt_modify_account(state, &diff, account_id)?;
}
Ok(diff)
@ -184,3 +170,21 @@ pub fn clock_invocation(timestamp: clock_core::Instruction) -> nssa::PublicTrans
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}
fn validate_doesnt_modify_account(
state: &V03State,
diff: &ValidatedStateDiff,
account_id: AccountId,
) -> Result<(), nssa::error::NssaError> {
if diff
.public_diff()
.get(&account_id)
.is_some_and(|post| *post != state.get_account_by_id(account_id))
{
Err(nssa::error::NssaError::InvalidInput(format!(
"Transaction modifies restricted system account {account_id}"
)))
} else {
Ok(())
}
}

View File

@ -22,6 +22,7 @@ token_core.workspace = true
ata_core.workspace = true
vault_core.workspace = true
faucet_core.workspace = true
bridge_core.workspace = true
indexer_service_rpc = { workspace = true, features = ["client"] }
sequencer_service_rpc = { workspace = true, features = ["client"] }
wallet-ffi.workspace = true
@ -34,3 +35,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
hex.workspace = true
tempfile.workspace = true
bytesize.workspace = true
reqwest.workspace = true
borsh.workspace = true
logos-blockchain-http-api-common.workspace = true
logos-blockchain-core.workspace = true

View File

@ -7,8 +7,14 @@ use integration_tests::{
public_mention, verify_commitment_is_in_state,
};
use log::info;
use nssa::{AccountId, program::Program};
use nssa_core::{NullifierPublicKey, encryption::shared_key_derivation::Secp256k1Point};
use nssa::{
AccountId, SharedSecretKey, execute_and_prove,
privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program,
};
use nssa_core::{
InputAccountIdentity, NullifierPublicKey, account::AccountWithMetadata,
encryption::shared_key_derivation::Secp256k1Point,
};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::{
@ -626,13 +632,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
}
#[test]
async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
use nssa::{
EphemeralPublicKey, SharedSecretKey, execute_and_prove,
privacy_preserving_transaction::{self, circuit::ProgramWithDependencies},
};
use nssa_core::{InputAccountIdentity, account::AccountWithMetadata};
async fn ppt_cant_chain_call_faucet() -> Result<()> {
let ctx = TestContext::new().await?;
let binary = std::fs::read(
@ -656,7 +656,6 @@ async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
let npk = NullifierPublicKey::from(&nsk);
let vpk = Secp256k1Point::from_scalar([4; 32]);
let ssk = SharedSecretKey::new([55; 32], &vpk);
let epk = EphemeralPublicKey::from_scalar([55; 32]);
let attacker_vault_id = {
let seed = vault_core::compute_vault_seed(attacker_id);
AccountId::for_private_pda(&vault_program_id, &seed, &npk, 1337)
@ -695,7 +694,7 @@ async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
let instruction =
Program::serialize_instruction((faucet_program_id, vault_program_id, attacker_id, amount))?;
let (output, proof) = execute_and_prove(
let res = execute_and_prove(
vec![faucet_pre, vault_pda_pre],
instruction,
vec![
@ -707,47 +706,9 @@ async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
},
],
&program_with_deps,
)?;
);
let message = privacy_preserving_transaction::Message::try_from_circuit_output(
vec![faucet_account_id],
vec![],
vec![(npk, vpk, epk)],
output,
)?;
let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]);
let attack_ppt = NSSATransaction::PrivacyPreserving(nssa::PrivacyPreservingTransaction::new(
message,
witness_set,
));
let faucet_balance_before = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(attacker_vault_id)
.await?;
let tx_hash = ctx.sequencer_client().send_transaction(attack_ppt).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let faucet_balance_after = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let vault_balance_after = ctx
.sequencer_client()
.get_account_balance(attacker_vault_id)
.await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(faucet_balance_after, faucet_balance_before);
assert_eq!(vault_balance_after, vault_balance_before);
assert!(tx_on_chain.is_none());
assert!(res.is_err());
Ok(())
}

View File

@ -420,7 +420,7 @@ async fn cannot_execute_faucet_program() -> Result<()> {
Program::faucet().id(),
vec![faucet_account_id, recipient_vault_id],
vec![],
faucet_core::Instruction::Transfer {
faucet_core::Instruction::TransferVault {
vault_program_id,
recipient_id: recipient,
amount,

View File

@ -0,0 +1,450 @@
#![expect(
clippy::tests_outside_test_module,
clippy::arithmetic_side_effects,
reason = "We don't care about these in tests"
)]
use std::time::Duration;
use anyhow::Context as _;
use borsh::BorshSerialize;
use common::transaction::NSSATransaction;
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext};
use log::info;
use logos_blockchain_core::mantle::{Value, ledger::Inputs, ops::channel::deposit::DepositOp};
use logos_blockchain_http_api_common::bodies::{
channel::ChannelDepositRequestBody,
wallet::{
balance::WalletBalanceResponseBody,
transfer_funds::{WalletTransferFundsRequestBody, WalletTransferFundsResponseBody},
},
};
use nssa::{
AccountId, execute_and_prove, privacy_preserving_transaction, program::Program,
public_transaction,
};
use nssa_core::{InputAccountIdentity, account::AccountWithMetadata};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
const TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK: Duration = Duration::from_mins(6);
#[test]
async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
let ctx = TestContext::new().await?;
let recipient_id = ctx.existing_public_accounts()[0];
let bridge_account_id = nssa::system_bridge_account_id();
let vault_program_id = Program::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
let message = public_transaction::Message::try_new(
Program::bridge().id(),
vec![bridge_account_id, recipient_vault_id],
vec![],
bridge_core::Instruction::Deposit {
vault_program_id,
recipient_id,
amount: 1,
},
)
.context("Failed to build public bridge deposit transaction")?;
let attack_tx = NSSATransaction::Public(nssa::PublicTransaction::new(
message,
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
));
let bridge_balance_before = ctx
.sequencer_client()
.get_account_balance(bridge_account_id)
.await?;
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let bridge_balance_after = ctx
.sequencer_client()
.get_account_balance(bridge_account_id)
.await?;
let vault_balance_after = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(bridge_balance_after, bridge_balance_before);
assert_eq!(vault_balance_after, vault_balance_before);
assert!(
tx_on_chain.is_none(),
"Direct public bridge::Deposit invocation should be rejected"
);
Ok(())
}
#[test]
async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
let ctx = TestContext::new().await?;
let recipient_id = ctx.existing_public_accounts()[0];
let bridge_account_id = nssa::system_bridge_account_id();
let vault_program_id = Program::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
// Get pre-state of bridge and vault accounts
let bridge_pre = AccountWithMetadata::new(
ctx.sequencer_client()
.get_account(bridge_account_id)
.await?,
false,
bridge_account_id,
);
let vault_pre = AccountWithMetadata::new(
ctx.sequencer_client()
.get_account(recipient_vault_id)
.await?,
false,
recipient_vault_id,
);
// Create program with dependencies
let program_with_deps =
nssa::privacy_preserving_transaction::circuit::ProgramWithDependencies::new(
Program::bridge(),
[
(vault_program_id, Program::vault()),
(
Program::authenticated_transfer_program().id(),
Program::authenticated_transfer_program(),
),
]
.into(),
);
// Serialize the bridge deposit instruction
let instruction = Program::serialize_instruction(bridge_core::Instruction::Deposit {
vault_program_id,
recipient_id,
amount: 1,
})
.context("Failed to serialize bridge deposit instruction")?;
// Execute and prove the bridge deposit
let (output, proof) = execute_and_prove(
vec![bridge_pre.clone(), vault_pre.clone()],
instruction,
vec![InputAccountIdentity::Public, InputAccountIdentity::Public],
&program_with_deps,
)
.context("Failed to execute/prove bridge deposit")?;
// Create privacy-preserving transaction from circuit output
let message = privacy_preserving_transaction::Message::try_from_circuit_output(
vec![bridge_account_id, recipient_vault_id],
vec![bridge_pre.account.nonce, vault_pre.account.nonce],
vec![],
output,
)
.context("Failed to build privacy-preserving bridge deposit message")?;
let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]);
let attack_tx = NSSATransaction::PrivacyPreserving(nssa::PrivacyPreservingTransaction::new(
message,
witness_set,
));
let bridge_balance_before = ctx
.sequencer_client()
.get_account_balance(bridge_account_id)
.await?;
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let bridge_balance_after = ctx
.sequencer_client()
.get_account_balance(bridge_account_id)
.await?;
let vault_balance_after = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(bridge_balance_after, bridge_balance_before);
assert_eq!(vault_balance_after, vault_balance_before);
assert!(
tx_on_chain.is_none(),
"Privacy-preserving bridge::Deposit invocation should be rejected"
);
Ok(())
}
async fn submit_bedrock_deposit(
bedrock_addr: std::net::SocketAddr,
recipient_id: AccountId,
amount: u128,
) -> anyhow::Result<()> {
#[derive(BorshSerialize)]
struct DepositMetadata {
recipient_id: AccountId,
}
// Encode deposit metadata
let metadata = borsh::to_vec(&DepositMetadata { recipient_id })
.context("Failed to encode deposit metadata")?;
let funding_key = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26";
let amount: Value = amount
.try_into()
.context("Deposit amount does not fit Bedrock Value type")?;
let channel_id = integration_tests::config::bedrock_channel_id();
let client = reqwest::Client::new();
let query_balance = || async {
let balance_response = client
.get(format!(
"http://{bedrock_addr}/wallet/{funding_key}/balance"
))
.send()
.await
.context("Failed to query Bedrock wallet balance")?;
let balance_response = check_response_success(balance_response).await?;
balance_response
.json::<WalletBalanceResponseBody>()
.await
.context("Failed to decode Bedrock balance response")
};
let mut balance = query_balance().await?;
info!(
"Queried Bedrock balance for key {funding_key}: {:?}",
balance.balance
);
if balance.balance < amount {
anyhow::bail!(
"Bedrock wallet with key {funding_key} has insufficient balance {:?} for deposit amount {:?}",
balance.balance,
amount
);
}
let mut selected_note_id = balance
.notes
.iter()
.find_map(|(note_id, value)| (*value == amount).then_some(*note_id));
if selected_note_id.is_none() {
let transfer_body = WalletTransferFundsRequestBody {
tip: None,
change_public_key: balance.address,
funding_public_keys: vec![balance.address],
recipient_public_key: balance.address,
amount,
};
let transfer_response = client
.post(format!(
"http://{bedrock_addr}/wallet/transactions/transfer-funds"
))
.json(&transfer_body)
.send()
.await
.context("Failed to submit Bedrock transfer-funds request")?;
let transfer_response = check_response_success(transfer_response).await?;
let transfer: WalletTransferFundsResponseBody = transfer_response
.json()
.await
.context("Failed to decode Bedrock transfer-funds response")?;
info!(
"Submitted transfer-funds to create exact deposit note, tx hash {:?}",
transfer.hash
);
let mut found_note = None;
for _ in 0..20 {
tokio::time::sleep(Duration::from_millis(500)).await;
balance = query_balance().await?;
found_note = balance
.notes
.iter()
.find_map(|(note_id, value)| (*value == amount).then_some(*note_id));
if found_note.is_some() {
break;
}
}
selected_note_id = found_note;
}
let Some(selected_note_id) = selected_note_id else {
anyhow::bail!(
"Failed to locate exact-value note {amount:?} for Bedrock deposit; available notes: {:?}",
balance.notes,
);
};
let body = ChannelDepositRequestBody {
tip: None,
deposit: DepositOp {
channel_id,
inputs: Inputs::new(vec![selected_note_id]),
metadata,
},
change_public_key: balance.address,
funding_public_keys: vec![balance.address],
max_tx_fee: 1_000_u64.into(),
};
let response = client
.post(format!("http://{bedrock_addr}/channel/deposit"))
.json(&body)
.send()
.await
.context("Failed to submit Bedrock deposit request")?;
let response = check_response_success(response).await?;
let body_text = response
.text()
.await
.unwrap_or_else(|_| "<failed to decode>".to_owned());
info!(
"Successfully submitted Bedrock deposit request for recipient {recipient_id} and amount {amount}, response body: {body_text}",
);
Ok(())
}
async fn check_response_success(response: reqwest::Response) -> anyhow::Result<reqwest::Response> {
if response.status().is_success() {
Ok(response)
} else {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
anyhow::bail!("Request failed with status {status} and body {body_text}");
}
}
async fn wait_for_vault_balance(
ctx: &TestContext,
vault_id: AccountId,
expected_balance: u128,
) -> anyhow::Result<()> {
let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK
+ Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS);
tokio::time::timeout(timeout, async {
loop {
let balance = ctx.sequencer_client().get_account_balance(vault_id).await?;
if balance == expected_balance {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
.await
.with_context(|| {
format!("Timed out waiting for vault {vault_id} balance to reach {expected_balance}")
})?
}
#[test]
async fn bedrock_deposit_mints_to_vault_then_claim_succeeds() -> anyhow::Result<()> {
let ctx = TestContext::new().await?;
let recipient_id = ctx.existing_public_accounts()[0];
let vault_program_id = Program::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let recipient_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient_id)
.await?;
// Submit deposit to Bedrock
submit_bedrock_deposit(ctx.bedrock_addr(), recipient_id, 1).await?;
// Wait for vault to receive the deposit (minted from bridge to vault)
wait_for_vault_balance(&ctx, recipient_vault_id, vault_balance_before + 1).await?;
// Now claim funds from vault back to recipient
let nonces = ctx
.wallet()
.get_accounts_nonces(vec![recipient_id])
.await
.context("Failed to get nonce for vault claim")?;
let signing_key = ctx
.wallet()
.storage()
.key_chain()
.pub_account_signing_key(recipient_id)
.with_context(|| format!("Missing signing key for account {recipient_id}"))?;
let claim_message = public_transaction::Message::try_new(
vault_program_id,
vec![recipient_id, recipient_vault_id],
nonces,
vault_core::Instruction::Claim { amount: 1 },
)
.context("Failed to build vault claim message")?;
let claim_witness_set =
public_transaction::WitnessSet::for_message(&claim_message, &[signing_key]);
let claim_tx = NSSATransaction::Public(nssa::PublicTransaction::new(
claim_message,
claim_witness_set,
));
let claim_hash = ctx.sequencer_client().send_transaction(claim_tx).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let claim_on_chain = ctx.sequencer_client().get_transaction(claim_hash).await?;
let vault_balance_after_claim = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let recipient_balance_after_claim = ctx
.sequencer_client()
.get_account_balance(recipient_id)
.await?;
assert!(
claim_on_chain.is_some(),
"Vault claim transaction must be included on-chain"
);
assert_eq!(
vault_balance_after_claim, vault_balance_before,
"Vault balance should return to initial state after claim"
);
assert_eq!(
recipient_balance_after_claim,
recipient_balance_before + 1,
"Recipient balance should increase by claimed amount"
);
Ok(())
}

View File

@ -48,6 +48,14 @@ pub struct MemPoolHandle<T> {
sender: Sender<T>,
}
impl<T> Clone for MemPoolHandle<T> {
fn clone(&self) -> Self {
Self {
sender: self.sender.clone(),
}
}
}
impl<T> MemPoolHandle<T> {
const fn new(sender: Sender<T>) -> Self {
Self { sender }

View File

@ -11,6 +11,7 @@ workspace = true
nssa_core = { workspace = true, features = ["host"] }
clock_core.workspace = true
faucet_core.workspace = true
bridge_core.workspace = true
anyhow.workspace = true
thiserror.workspace = true

View File

@ -18,7 +18,7 @@ pub use public_transaction::PublicTransaction;
pub use signature::{PrivateKey, PublicKey, Signature};
pub use state::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
CLOCK_PROGRAM_ACCOUNT_IDS, V03State, system_faucet_account_id,
CLOCK_PROGRAM_ACCOUNT_IDS, V03State, system_bridge_account_id, system_faucet_account_id,
};
pub use validated_state_diff::ValidatedStateDiff;

View File

@ -10,8 +10,9 @@ use crate::{
error::NssaError,
program_methods::{
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, FAUCET_ELF,
FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF,
CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF,
VAULT_ID,
},
};
@ -164,6 +165,14 @@ impl Program {
elf: FAUCET_ELF.to_vec(),
}
}
#[must_use]
pub fn bridge() -> Self {
Self {
id: BRIDGE_ID,
elf: BRIDGE_ELF.to_vec(),
}
}
}
// TODO: Testnet only. Refactor to prevent compilation on mainnet.
@ -194,9 +203,9 @@ mod tests {
program::Program,
program_methods::{
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, FAUCET_ELF,
FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, TOKEN_ELF,
TOKEN_ID, VAULT_ELF, VAULT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID,
CLOCK_ELF, CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF,
PINATA_TOKEN_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID,
},
};
@ -529,6 +538,7 @@ mod tests {
let token_program = Program::token();
let vault_program = Program::vault();
let faucet_program = Program::faucet();
let bridge_program = Program::bridge();
let pinata_program = Program::pinata();
assert_eq!(auth_transfer_program.id, AUTHENTICATED_TRANSFER_ID);
@ -539,6 +549,8 @@ mod tests {
assert_eq!(vault_program.elf, VAULT_ELF);
assert_eq!(faucet_program.id, FAUCET_ID);
assert_eq!(faucet_program.elf, FAUCET_ELF);
assert_eq!(bridge_program.id, BRIDGE_ID);
assert_eq!(bridge_program.elf, BRIDGE_ELF);
assert_eq!(pinata_program.id, PINATA_ID);
assert_eq!(pinata_program.elf, PINATA_ELF);
}
@ -551,6 +563,7 @@ mod tests {
(ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID),
(CLOCK_ELF, CLOCK_ID),
(FAUCET_ELF, FAUCET_ID),
(BRIDGE_ELF, BRIDGE_ID),
(PINATA_ELF, PINATA_ID),
(PINATA_TOKEN_ELF, PINATA_TOKEN_ID),
(TOKEN_ELF, TOKEN_ID),

View File

@ -126,8 +126,11 @@ impl Default for V03State {
fn default() -> Self {
let faucet_account_id = system_faucet_account_id();
let faucet_account = system_faucet_account();
let bridge_account_id = system_bridge_account_id();
let bridge_account = system_bridge_account();
let mut public_state = HashMap::new();
public_state.insert(faucet_account_id, faucet_account);
public_state.insert(bridge_account_id, bridge_account);
Self {
public_state,
@ -150,6 +153,7 @@ impl V03State {
genesis_timestamp: nssa_core::Timestamp,
) -> Self {
let faucet_account_id = system_faucet_account_id();
let bridge_account_id = system_bridge_account_id();
let authenticated_transfer_program = Program::authenticated_transfer_program();
let mut public_state: HashMap<_, _> = initial_data
.iter()
@ -164,7 +168,9 @@ impl V03State {
})
.collect();
let faucet_account = system_faucet_account();
let bridge_account = system_bridge_account();
public_state.insert(faucet_account_id, faucet_account);
public_state.insert(bridge_account_id, bridge_account);
let mut commitment_set = CommitmentSet::with_capacity(32);
commitment_set.extend(&[DUMMY_COMMITMENT]);
@ -190,6 +196,7 @@ impl V03State {
this.insert_program(Program::ata());
this.insert_program(Program::vault());
this.insert_program(Program::faucet());
this.insert_program(Program::bridge());
this
}
@ -384,11 +391,23 @@ fn system_faucet_account() -> Account {
}
}
fn system_bridge_account() -> Account {
Account {
program_owner: Program::authenticated_transfer_program().id(),
..Account::default()
}
}
#[must_use]
pub fn system_faucet_account_id() -> AccountId {
faucet_core::compute_faucet_account_id(Program::faucet().id())
}
#[must_use]
pub fn system_bridge_account_id() -> AccountId {
bridge_core::compute_bridge_account_id(Program::bridge().id())
}
#[cfg(test)]
pub mod tests {
#![expect(
@ -426,9 +445,10 @@ pub mod tests {
signature::PrivateKey,
state::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS, system_faucet_account,
CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS, system_bridge_account,
system_faucet_account,
},
system_faucet_account_id,
system_bridge_account_id, system_faucet_account_id,
};
impl V03State {
@ -622,6 +642,7 @@ pub mod tests {
},
);
this.insert(system_faucet_account_id(), system_faucet_account());
this.insert(system_bridge_account_id(), system_bridge_account());
for account_id in CLOCK_PROGRAM_ACCOUNT_IDS {
this.insert(
account_id,
@ -646,6 +667,7 @@ pub mod tests {
this.insert(Program::ata().id(), Program::ata());
this.insert(Program::vault().id(), Program::vault());
this.insert(Program::faucet().id(), Program::faucet());
this.insert(Program::bridge().id(), Program::bridge());
this
};

View File

@ -18,6 +18,7 @@ amm_program.workspace = true
ata_core.workspace = true
ata_program.workspace = true
faucet_core.workspace = true
bridge_core.workspace = true
vault_core.workspace = true
risc0-zkvm.workspace = true
serde = { workspace = true, default-features = false }

View File

@ -0,0 +1,82 @@
use bridge_core::Instruction;
use nssa_core::program::{
AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_nssa_inputs,
};
fn unchanged_post_states(
pre_states: &[nssa_core::account::AccountWithMetadata],
) -> Vec<AccountPostState> {
pre_states
.iter()
.map(|pre_state| AccountPostState::new(pre_state.account.clone()))
.collect()
}
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction,
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
assert!(
caller_program_id.is_none(),
"Bridge cannot be invoked through chain calls"
);
let pre_states_clone = pre_states.clone();
let post_states = unchanged_post_states(&pre_states_clone);
let chained_calls = match instruction {
Instruction::Deposit {
vault_program_id,
recipient_id,
amount,
} => {
let [bridge, recipient_vault] = pre_states
.try_into()
.expect("Deposit requires exactly 2 accounts");
assert_eq!(
bridge.account_id,
bridge_core::compute_bridge_account_id(self_program_id),
"First account must be bridge PDA"
);
assert_eq!(
recipient_vault.account_id,
vault_core::compute_vault_account_id(vault_program_id, recipient_id),
"Second account must be recipient vault PDA"
);
let mut bridge_for_vault = bridge;
bridge_for_vault.is_authorized = true;
vec![
ChainedCall::new(
vault_program_id,
vec![bridge_for_vault, recipient_vault],
&vault_core::Instruction::Transfer {
recipient_id,
amount,
},
)
.with_pda_seeds(vec![bridge_core::compute_bridge_seed()]),
]
}
};
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states_clone,
post_states,
)
.with_chained_calls(chained_calls)
.write();
}

View File

@ -23,11 +23,16 @@ fn main() {
instruction_words,
) = read_nssa_inputs::<Instruction>();
assert!(
caller_program_id.is_none(),
"Faucet cannot be invoked through chain calls"
);
let pre_states_clone = pre_states.clone();
let post_states = unchanged_post_states(&pre_states_clone);
let chained_calls = match instruction {
Instruction::Transfer {
Instruction::TransferVault {
vault_program_id,
recipient_id,
amount,
@ -57,6 +62,29 @@ fn main() {
.with_pda_seeds(vec![faucet_core::compute_faucet_seed()]),
]
}
Instruction::TransferDirect { amount } => {
let [faucet, recipient] = pre_states
.try_into()
.expect("TransferDirect requires exactly 2 accounts");
assert_eq!(
faucet.account_id,
faucet_core::compute_faucet_account_id(self_program_id),
"First account must be faucet PDA"
);
let mut faucet_for_transfer = faucet;
faucet_for_transfer.is_authorized = true;
vec![
ChainedCall::new(
faucet_for_transfer.account.program_owner,
vec![faucet_for_transfer, recipient],
&authenticated_transfer_core::Instruction::Transfer { amount },
)
.with_pda_seeds(vec![faucet_core::compute_faucet_seed()]),
]
}
};
ProgramOutput::new(

View File

@ -0,0 +1,12 @@
[package]
name = "bridge_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
nssa_core.workspace = true
serde = { workspace = true, default-features = false }

View File

@ -0,0 +1,29 @@
pub use nssa_core::program::PdaSeed;
use nssa_core::{account::AccountId, program::ProgramId};
use serde::{Deserialize, Serialize};
const BRIDGE_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/BridgeSeed/0000000000/";
#[derive(Serialize, Deserialize)]
pub enum Instruction {
/// Transfers native tokens from the bridge PDA account to a recipient vault.
///
/// Required accounts (2):
/// - Bridge PDA account
/// - Recipient vault PDA account
Deposit {
vault_program_id: ProgramId,
recipient_id: AccountId,
amount: u128,
},
}
#[must_use]
pub const fn compute_bridge_seed() -> PdaSeed {
PdaSeed::new(BRIDGE_SEED_DOMAIN_SEPARATOR)
}
#[must_use]
pub fn compute_bridge_account_id(bridge_program_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&bridge_program_id, &compute_bridge_seed())
}

View File

@ -11,11 +11,18 @@ pub enum Instruction {
/// Required accounts (2):
/// - Faucet PDA account
/// - Recipient vault PDA account
Transfer {
TransferVault {
vault_program_id: ProgramId,
recipient_id: AccountId,
amount: u128,
},
/// Transfers native tokens from system faucet directly to a recipient account.
///
/// Required accounts (2):
/// - Faucet PDA account
/// - Recipient account
TransferDirect { amount: u128 },
}
#[must_use]

View File

@ -22,6 +22,9 @@ pub enum GenesisAction {
account_id: AccountId,
balance: u128,
},
SupplyBridgeAccount {
balance: u128,
},
}
// TODO: Provide default values

View File

@ -28,10 +28,18 @@ pub mod config;
#[cfg(feature = "mock")]
pub mod mock;
/// The origin of a transaction.
pub enum TransactionOrigin {
/// Basic transactions submitted by users via RPC.
User,
/// Transactions generated by the sequencer itself.
Sequencer,
}
pub struct SequencerCore<BP: BlockPublisherTrait = ZoneSdkPublisher> {
state: nssa::V03State,
store: SequencerStore,
mempool: MemPool<NSSATransaction>,
mempool: MemPool<(TransactionOrigin, NSSATransaction)>,
sequencer_config: SequencerConfig,
chain_height: u64,
block_publisher: BP,
@ -45,7 +53,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
/// initializing its state with the accounts defined in the configuration file.
pub async fn start_from_config(
config: SequencerConfig,
) -> (Self, MemPoolHandle<NSSATransaction>) {
) -> (Self, MemPoolHandle<(TransactionOrigin, NSSATransaction)>) {
let signing_key = nssa::PrivateKey::try_new(config.signing_key).unwrap();
let bedrock_signing_key =
@ -207,7 +215,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let clock_tx = clock_invocation(new_block_timestamp);
let clock_nssa_tx = NSSATransaction::Public(clock_tx.clone());
while let Some(tx) = self.mempool.pop() {
while let Some((origin, tx)) = self.mempool.pop() {
let tx_hash = tx.hash();
// Check if block size exceeds limit (including the mandatory clock tx).
@ -235,25 +243,41 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
block size {block_size} bytes would exceed limit of {max_block_size} bytes",
);
self.mempool.push_front(tx);
self.mempool.push_front((origin, tx));
break;
}
let validated_diff = match tx.validate_on_state(
&self.state,
new_block_height,
new_block_timestamp,
) {
Ok(diff) => diff,
Err(err) => {
error!(
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
);
continue;
}
};
match origin {
TransactionOrigin::User => {
let validated_diff = match tx.validate_on_state(
&self.state,
new_block_height,
new_block_timestamp,
) {
Ok(diff) => diff,
Err(err) => {
error!(
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
);
continue;
}
};
self.state.apply_state_diff(validated_diff);
self.state.apply_state_diff(validated_diff);
}
TransactionOrigin::Sequencer => {
let NSSATransaction::Public(public_tx) = &tx else {
panic!("Sequencer may only generate Public transactions, found {tx:#?}");
};
self.state
.transition_from_public_transaction(
public_tx,
new_block_height,
new_block_timestamp,
)
.context("Failed to execute sequencer-generated transaction")?;
}
}
valid_transactions.push(tx);
info!("Validated transaction with hash {tx_hash}, including it in block");
@ -363,6 +387,9 @@ fn build_genesis_state(config: &SequencerConfig) -> (nssa::V03State, Vec<NSSATra
account_id,
balance,
} => build_supply_account_genesis_transaction(account_id, *balance),
GenesisAction::SupplyBridgeAccount { balance } => {
build_supply_bridge_account_genesis_transaction(*balance)
}
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
@ -388,7 +415,7 @@ fn build_supply_account_genesis_transaction(
faucet_program_id,
vec![nssa::system_faucet_account_id(), recipient_vault_id],
vec![],
faucet_core::Instruction::Transfer {
faucet_core::Instruction::TransferVault {
vault_program_id,
recipient_id: *account_id,
amount: balance,
@ -400,6 +427,22 @@ fn build_supply_account_genesis_transaction(
PublicTransaction::new(message, witness_set)
}
fn build_supply_bridge_account_genesis_transaction(balance: u128) -> PublicTransaction {
let faucet_program_id = Program::faucet().id();
let bridge_account_id = nssa::system_bridge_account_id();
let message = Message::try_new(
faucet_program_id,
vec![nssa::system_faucet_account_id(), bridge_account_id],
vec![],
faucet_core::Instruction::TransferDirect { amount: balance },
)
.expect("Failed to serialize bridge genesis transfer instruction");
let witness_set = nssa::public_transaction::WitnessSet::from_raw_parts(vec![]);
PublicTransaction::new(message, witness_set)
}
/// Load signing key from file or generate a new one if it doesn't exist.
fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
if path.exists() {
@ -441,6 +484,7 @@ mod tests {
use testnet_initial_state::{initial_accounts, initial_pub_accounts_private_keys};
use crate::{
TransactionOrigin,
block_store::SequencerStore,
build_genesis_state,
config::{BedrockConfig, SequencerConfig},
@ -476,19 +520,28 @@ mod tests {
initial_pub_accounts_private_keys()[1].pub_sign_key.clone()
}
async fn common_setup() -> (SequencerCoreWithMockClients, MemPoolHandle<NSSATransaction>) {
async fn common_setup() -> (
SequencerCoreWithMockClients,
MemPoolHandle<(TransactionOrigin, NSSATransaction)>,
) {
let config = setup_sequencer_config();
common_setup_with_config(config).await
}
async fn common_setup_with_config(
config: SequencerConfig,
) -> (SequencerCoreWithMockClients, MemPoolHandle<NSSATransaction>) {
) -> (
SequencerCoreWithMockClients,
MemPoolHandle<(TransactionOrigin, NSSATransaction)>,
) {
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle.push(tx).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
@ -678,10 +731,13 @@ mod tests {
let tx = common::test_utils::produce_dummy_empty_transaction();
// Fill the mempool
mempool_handle.push(tx.clone()).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
// Check that pushing another transaction will block
let mut push_fut = pin!(mempool_handle.push(tx.clone()));
let mut push_fut = pin!(mempool_handle.push((TransactionOrigin::User, tx.clone())));
let poll = futures::poll!(push_fut.as_mut());
assert!(poll.is_pending());
@ -698,7 +754,10 @@ mod tests {
let genesis_height = sequencer.chain_height;
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle.push(tx).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
let result = sequencer.build_block_from_mempool();
assert!(result.is_ok());
@ -721,8 +780,14 @@ mod tests {
let tx_original = tx.clone();
let tx_replay = tx.clone();
// Pushing two copies of the same tx to the mempool
mempool_handle.push(tx_original).await.unwrap();
mempool_handle.push(tx_replay).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx_original))
.await
.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx_replay))
.await
.unwrap();
// Create block
sequencer.produce_new_block().await.unwrap();
@ -756,7 +821,10 @@ mod tests {
);
// The transaction should be included the first time
mempool_handle.push(tx.clone()).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
@ -772,7 +840,10 @@ mod tests {
);
// Add same transaction should fail
mempool_handle.push(tx.clone()).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
@ -811,7 +882,10 @@ mod tests {
&signing_key,
);
mempool_handle.push(tx.clone()).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
@ -895,7 +969,10 @@ mod tests {
&signing_key,
);
mempool_handle.push(tx).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
// Get the metadata of the last block produced
@ -916,7 +993,10 @@ mod tests {
&signing_key,
);
mempool_handle.push(tx.clone()).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
// Step 4: Produce new block
sequencer.produce_new_block().await.unwrap();
@ -962,10 +1042,16 @@ mod tests {
))
};
mempool_handle
.push(NSSATransaction::Public(clock_invocation(0)))
.push((
TransactionOrigin::User,
NSSATransaction::Public(clock_invocation(0)),
))
.await
.unwrap();
mempool_handle
.push((TransactionOrigin::User, crafted_clock_tx))
.await
.unwrap();
mempool_handle.push(crafted_clock_tx).await.unwrap();
sequencer.produce_new_block().await.unwrap();
let block = sequencer
@ -994,7 +1080,10 @@ mod tests {
test_program_methods::CLOCK_CHAIN_CALLER_ELF.to_vec(),
),
));
mempool_handle.push(deploy_tx).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, deploy_tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
// Build a user transaction that invokes clock_chain_caller, which in turn chain-calls the
@ -1019,7 +1108,10 @@ mod tests {
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
));
mempool_handle.push(user_tx).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, user_tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block = sequencer
@ -1051,7 +1143,10 @@ mod tests {
// Push a dummy transaction so the mempool is non-empty.
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle.push(tx).await.unwrap();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
// Block production must fail because the appended clock tx cannot execute.
let result = sequencer.produce_new_block().await;

View File

@ -11,9 +11,13 @@ workspace = true
common.workspace = true
nssa.workspace = true
mempool.workspace = true
bridge_core.workspace = true
vault_core.workspace = true
sequencer_core = { workspace = true, features = ["testnet"] }
sequencer_service_protocol.workspace = true
sequencer_service_rpc = { workspace = true, features = ["server"] }
logos-blockchain-zone-sdk.workspace = true
logos-blockchain-core.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
anyhow.workspace = true

View File

@ -1,16 +1,29 @@
use std::{net::SocketAddr, sync::Arc, time::Duration};
use anyhow::{Context as _, Result, anyhow};
#[cfg(not(feature = "standalone"))]
use borsh::BorshDeserialize;
use bytesize::ByteSize;
use common::transaction::NSSATransaction;
#[cfg(not(feature = "standalone"))]
use futures::StreamExt as _;
use futures::never::Never;
use jsonrpsee::server::ServerHandle;
#[cfg(not(feature = "standalone"))]
use log::warn;
use log::{error, info};
#[cfg(not(feature = "standalone"))]
use logos_blockchain_core::mantle::ops::channel::MsgId;
#[cfg(not(feature = "standalone"))]
use logos_blockchain_zone_sdk::{
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use mempool::MemPoolHandle;
#[cfg(not(feature = "standalone"))]
use sequencer_core::SequencerCore;
#[cfg(feature = "standalone")]
use sequencer_core::SequencerCoreWithMockClients as SequencerCore;
use sequencer_core::TransactionOrigin;
pub use sequencer_core::config::*;
use sequencer_service_rpc::RpcServer as _;
use tokio::{sync::Mutex, task::JoinHandle};
@ -19,6 +32,19 @@ pub mod service;
const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10);
#[cfg(not(feature = "standalone"))]
#[derive(Clone, Debug, BorshDeserialize)]
struct DepositMetadata {
recipient_id: nssa::AccountId,
}
#[cfg(not(feature = "standalone"))]
impl DepositMetadata {
fn decode(bytes: &[u8]) -> Result<Self, std::io::Error> {
Self::try_from_slice(bytes)
}
}
/// Handle to manage the sequencer and its tasks.
///
/// Implements `Drop` to ensure all tasks are aborted and the RPC server is stopped when dropped.
@ -27,6 +53,7 @@ pub struct SequencerHandle {
/// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`.
server_handle: Option<ServerHandle>,
main_loop_handle: JoinHandle<Result<Never>>,
bedrock_deposit_loop_handle: Option<JoinHandle<Result<Never>>>,
}
impl SequencerHandle {
@ -34,11 +61,13 @@ impl SequencerHandle {
addr: SocketAddr,
server_handle: ServerHandle,
main_loop_handle: JoinHandle<Result<Never>>,
bedrock_deposit_loop_handle: Option<JoinHandle<Result<Never>>>,
) -> Self {
Self {
addr,
server_handle: Some(server_handle),
main_loop_handle,
bedrock_deposit_loop_handle,
}
}
@ -52,18 +81,25 @@ impl SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
bedrock_deposit_loop_handle,
} = &mut self;
let server_handle = server_handle.take().expect("Server handle is set");
let deposit_opt_fut =
futures::future::OptionFuture::from(bedrock_deposit_loop_handle.take());
tokio::select! {
() = server_handle.stopped() => {
Err(anyhow!("RPC Server stopped"))
}
res = main_loop_handle => {
res
.context("Main loop task panicked")?
.context("Main loop exited unexpectedly")
.context("Main loop task panicked")?
.context("Main loop exited unexpectedly")
}
Some(res) = deposit_opt_fut => {
res
.context("Bedrock deposit loop task panicked")?
.context("Bedrock deposit loop exited unexpectedly")
}
}
}
@ -78,10 +114,14 @@ impl SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
bedrock_deposit_loop_handle,
} = self;
let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped)
|| main_loop_handle.is_finished();
|| main_loop_handle.is_finished()
|| bedrock_deposit_loop_handle
.as_ref()
.is_some_and(JoinHandle::is_finished);
!stopped
}
@ -97,9 +137,13 @@ impl Drop for SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
bedrock_deposit_loop_handle,
} = self;
main_loop_handle.abort();
if let Some(handle) = bedrock_deposit_loop_handle {
handle.abort();
}
let Some(handle) = server_handle else {
return;
@ -114,16 +158,19 @@ impl Drop for SequencerHandle {
pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle> {
let block_timeout = config.block_create_timeout;
let max_block_size = config.max_block_size;
#[cfg(not(feature = "standalone"))]
let bedrock_config = config.bedrock_config.clone();
let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await;
info!("Sequencer core set up");
let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core));
let mempool_handle_for_server = mempool_handle.clone();
let (server_handle, addr) = run_server(
Arc::clone(&seq_core_wrapped),
mempool_handle,
mempool_handle_for_server,
port,
max_block_size.as_u64(),
)
@ -133,12 +180,31 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle>
info!("Starting main sequencer loop");
let main_loop_handle = tokio::spawn(main_loop(seq_core_wrapped, block_timeout));
Ok(SequencerHandle::new(addr, server_handle, main_loop_handle))
#[cfg(not(feature = "standalone"))]
let bedrock_deposit_loop_handle = {
info!("Starting Bedrock deposit listener loop");
Some(tokio::spawn(bedrock_deposit_loop(
bedrock_config,
mempool_handle,
)))
};
#[cfg(feature = "standalone")]
let bedrock_deposit_loop_handle = {
let _ = mempool_handle;
None
};
Ok(SequencerHandle::new(
addr,
server_handle,
main_loop_handle,
bedrock_deposit_loop_handle,
))
}
async fn run_server(
sequencer: Arc<Mutex<SequencerCore>>,
mempool_handle: MemPoolHandle<NSSATransaction>,
mempool_handle: MemPoolHandle<(TransactionOrigin, NSSATransaction)>,
port: u16,
max_block_size: u64,
) -> Result<(ServerHandle, SocketAddr)> {
@ -182,3 +248,100 @@ async fn main_loop(seq_core: Arc<Mutex<SequencerCore>>, block_timeout: Duration)
info!("Waiting for new transactions");
}
}
#[cfg(not(feature = "standalone"))]
async fn bedrock_deposit_loop(
bedrock_config: BedrockConfig,
mempool_handle: MemPoolHandle<(TransactionOrigin, NSSATransaction)>,
) -> Result<Never> {
let basic_auth = bedrock_config.auth.map(Into::into);
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), bedrock_config.node_url);
let zone_indexer = ZoneIndexer::new(bedrock_config.channel_id, node);
let mut cursor: Option<(MsgId, Slot)> = None;
let poll_interval = Duration::from_secs(1);
// Short-term fix: dummy MsgId so zone-sdk skips the whole slot on re-poll.
// TODO: drop once zone-sdk indexer API is updated to take only `Slot`.
let dummy_msg_id = MsgId::from([0xff_u8; 32]);
loop {
let stream = match zone_indexer.next_messages(cursor).await {
Ok(stream) => stream,
Err(err) => {
error!("Failed to start Bedrock deposit stream: {err}");
tokio::time::sleep(poll_interval).await;
continue;
}
};
let mut stream = std::pin::pin!(stream);
while let Some((msg, slot)) = stream.next().await {
cursor = Some((dummy_msg_id, slot));
match msg {
ZoneMessage::Block(block) => {
info!("Observed Bedrock channel block id {:?}", block.id);
}
ZoneMessage::Deposit(deposit) => {
let metadata = match DepositMetadata::decode(&deposit.metadata) {
Ok(metadata) => metadata,
Err(err) => {
warn!("Skipping Bedrock Deposit with invalid metadata: {err}");
continue;
}
};
let tx = match build_bridge_deposit_tx(&metadata) {
Ok(tx) => tx,
Err(err) => {
warn!("Skipping Bedrock Deposit due to tx build failure: {err:#}");
continue;
}
};
info!(
"Observed Bedrock Deposit for recipient {recipient_id}, pushing to mempool",
recipient_id = metadata.recipient_id
);
mempool_handle
.push((TransactionOrigin::Sequencer, tx))
.await
.context("Mempool is closed while pushing Bedrock Deposit transaction")?;
}
ZoneMessage::Withdraw(_) => {}
}
}
tokio::time::sleep(poll_interval).await;
}
}
#[cfg(not(feature = "standalone"))]
fn build_bridge_deposit_tx(metadata: &DepositMetadata) -> Result<NSSATransaction> {
// TODO: Remove this constant once we have a way to get the deposit amount from Bedrock deposit
// inputs.
const TEMPORARY_BRIDGE_DEPOSIT_AMOUNT: u128 = 1;
let bridge_program_id = nssa::program::Program::bridge().id();
let vault_program_id = nssa::program::Program::vault().id();
let recipient_vault_id =
vault_core::compute_vault_account_id(vault_program_id, metadata.recipient_id);
let message = nssa::public_transaction::Message::try_new(
bridge_program_id,
vec![nssa::system_bridge_account_id(), recipient_vault_id],
vec![],
bridge_core::Instruction::Deposit {
vault_program_id,
recipient_id: metadata.recipient_id,
amount: TEMPORARY_BRIDGE_DEPOSIT_AMOUNT,
},
)
.context("Failed to build bridge deposit message")?;
let witness_set = nssa::public_transaction::WitnessSet::from_raw_parts(vec![]);
Ok(NSSATransaction::Public(nssa::PublicTransaction::new(
message,
witness_set,
)))
}

View File

@ -8,7 +8,9 @@ use jsonrpsee::{
use log::warn;
use mempool::MemPoolHandle;
use nssa::{self, program::Program};
use sequencer_core::{DbError, SequencerCore, block_publisher::BlockPublisherTrait};
use sequencer_core::{
DbError, SequencerCore, TransactionOrigin, block_publisher::BlockPublisherTrait,
};
use sequencer_service_protocol::{
Account, AccountId, Block, BlockId, Commitment, HashType, MembershipProof, Nonce, ProgramId,
};
@ -18,14 +20,14 @@ const NOT_FOUND_ERROR_CODE: i32 = -31999;
pub struct SequencerService<BC: BlockPublisherTrait> {
sequencer: Arc<Mutex<SequencerCore<BC>>>,
mempool_handle: MemPoolHandle<NSSATransaction>,
mempool_handle: MemPoolHandle<(TransactionOrigin, NSSATransaction)>,
max_block_size: u64,
}
impl<BC: BlockPublisherTrait> SequencerService<BC> {
pub const fn new(
sequencer: Arc<Mutex<SequencerCore<BC>>>,
mempool_handle: MemPoolHandle<NSSATransaction>,
mempool_handle: MemPoolHandle<(TransactionOrigin, NSSATransaction)>,
max_block_size: u64,
) -> Self {
Self {
@ -72,7 +74,7 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
})?;
self.mempool_handle
.push(authenticated_tx)
.push((TransactionOrigin::User, authenticated_tx))
.await
.expect("Mempool is closed, this is a bug");

View File

@ -143,7 +143,12 @@ pub fn genesis_from_accounts(
balance: account.balance,
});
public_genesis.chain(private_genesis).collect()
let supply_bridge_account = GenesisAction::SupplyBridgeAccount { balance: 1_000_000 };
public_genesis
.chain(private_genesis)
.chain(std::iter::once(supply_bridge_account))
.collect()
}
pub fn wallet_config(sequencer_addr: SocketAddr) -> Result<WalletConfig> {
@ -184,7 +189,8 @@ pub fn addr_to_url(protocol: UrlProtocol, addr: SocketAddr) -> Result<Url> {
url_string.parse().map_err(Into::into)
}
fn bedrock_channel_id() -> ChannelId {
#[must_use]
pub fn bedrock_channel_id() -> ChannelId {
let channel_id: [u8; 32] = [0_u8, 1]
.repeat(16)
.try_into()

View File

@ -30,7 +30,7 @@ fn main() {
let chained_calls = vec![ChainedCall {
program_id: faucet_program_id,
instruction_data: to_vec(&faucet_core::Instruction::Transfer {
instruction_data: to_vec(&faucet_core::Instruction::TransferVault {
vault_program_id,
recipient_id,
amount,