feat: move initial_accounts and initial_commitments into genesis

This commit is contained in:
Daniil Polyakov 2026-04-16 01:05:22 +03:00
parent 0b070e5ad2
commit 9c2d353ebd
57 changed files with 645 additions and 309 deletions

13
Cargo.lock generated
View File

@ -2794,6 +2794,14 @@ dependencies = [
"typenum",
]
[[package]]
name = "genesis_supply_account_core"
version = "0.1.0"
dependencies = [
"nssa_core",
"serde",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -3611,7 +3619,6 @@ dependencies = [
"serde_json",
"tempfile",
"testcontainers",
"testnet_initial_state",
"token_core",
"tokio",
"url",
@ -3998,7 +4005,6 @@ dependencies = [
"hmac-sha512",
"itertools 0.14.0",
"k256",
"log",
"nssa",
"nssa_core",
"rand 0.8.5",
@ -6097,6 +6103,7 @@ dependencies = [
"ata_core",
"ata_program",
"clock_core",
"genesis_supply_account_core",
"nssa_core",
"risc0-zkvm",
"serde",
@ -7347,8 +7354,10 @@ dependencies = [
"chrono",
"common",
"futures",
"genesis_supply_account_core",
"humantime-serde",
"jsonrpsee",
"key_protocol",
"log",
"logos-blockchain-core",
"logos-blockchain-key-management-system-service",

View File

@ -20,6 +20,7 @@ members = [
"programs/token",
"programs/associated_token_account/core",
"programs/associated_token_account",
"programs/genesis_supply_account/core",
"sequencer/core",
"sequencer/service",
"sequencer/service/protocol",
@ -64,6 +65,7 @@ amm_core = { path = "programs/amm/core" }
amm_program = { path = "programs/amm" }
ata_core = { path = "programs/associated_token_account/core" }
ata_program = { path = "programs/associated_token_account" }
genesis_supply_account_core = { path = "programs/genesis_supply_account/core" }
test_program_methods = { path = "test_program_methods" }
bedrock_client = { path = "bedrock_client" }
testnet_initial_state = { path = "testnet_initial_state" }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -76,7 +76,7 @@ impl NSSATransaction {
) -> Result<ValidatedStateDiff, nssa::error::NssaError> {
let diff = match self {
Self::Public(tx) => {
ValidatedStateDiff::from_public_transaction(tx, state, block_id, timestamp)
ValidatedStateDiff::from_public_transaction(tx, state, block_id, timestamp, false)
}
Self::PrivacyPreserving(tx) => ValidatedStateDiff::from_privacy_preserving_transaction(
tx, state, block_id, timestamp,

View File

@ -16,117 +16,29 @@
"node_url": "http://logos-blockchain-node-0:18080"
},
"indexer_rpc_url": "ws://indexer_service:8779",
"initial_accounts": [
"genesis": [
{
"account_id": "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV",
"balance": 10000
},
{
"account_id": "7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo",
"balance": 20000
}
],
"initial_commitments": [
{
"npk":[
177,
64,
1,
11,
87,
38,
254,
159,
231,
165,
1,
94,
64,
137,
243,
76,
249,
101,
251,
129,
33,
101,
189,
30,
42,
11,
191,
34,
103,
186,
227,
230
] ,
"account": {
"program_owner": [
0,
0,
0,
0,
0,
0,
0,
0
],
"balance": 10000,
"data": [],
"nonce": 0
"supply_public_account": {
"account_id": "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV",
"balance": 10000
}
},
{
"npk": [
32,
67,
72,
164,
106,
53,
66,
239,
141,
15,
52,
230,
136,
177,
2,
236,
207,
243,
134,
135,
210,
143,
87,
232,
215,
128,
194,
120,
113,
224,
4,
165
],
"account": {
"program_owner": [
0,
0,
0,
0,
0,
0,
0,
0
],
"balance": 20000,
"data": [],
"nonce": 0
"supply_public_account": {
"account_id": "7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo",
"balance": 20000
}
},
{
"supply_private_account": {
"npk": [177,64,1,11,87,38,254,159,231,165,1,94,64,137,243,76,249,101,251,129,33,101,189,30,42,11,191,34,103,186,227,230],
"balance": 10000
}
},
{
"supply_private_account": {
"npk": [32,67,72,164,106,53,66,239,141,15,52,230,136,177,2,236,207,243,134,135,210,143,87,232,215,128,194,120,113,224,4,165],
"balance": 20000
}
}
],

View File

@ -22,10 +22,8 @@ ata_core.workspace = true
indexer_service_rpc.workspace = true
sequencer_service_rpc = { workspace = true, features = ["client"] }
wallet-ffi.workspace = true
testnet_initial_state.workspace = true
url.workspace = true
anyhow.workspace = true
env_logger.workspace = true
log.workspace = true

View File

@ -6,8 +6,7 @@ use indexer_service::{BackoffConfig, ChannelId, ClientConfig, IndexerConfig};
use key_protocol::key_management::KeyChain;
use nssa::{Account, AccountId, PrivateKey, PublicKey};
use nssa_core::{account::Data, program::DEFAULT_PROGRAM_ID};
use sequencer_core::config::{BedrockConfig, SequencerConfig};
use testnet_initial_state::{PrivateAccountPublicInitialData, PublicAccountPublicInitialData};
use sequencer_core::config::{BedrockConfig, GenesisTransaction, SequencerConfig};
use url::Url;
use wallet::config::WalletConfig;
@ -100,27 +99,24 @@ impl InitialData {
}
}
fn sequencer_initial_public_accounts(&self) -> Vec<PublicAccountPublicInitialData> {
fn sequencer_genesis(&self) -> Vec<GenesisTransaction> {
self.public_accounts
.iter()
.map(|(priv_key, balance)| {
let pub_key = PublicKey::new_from_private_key(priv_key);
let account_id = AccountId::from(&pub_key);
PublicAccountPublicInitialData {
GenesisTransaction::SupplyPublicAccount {
account_id,
balance: *balance,
}
})
.collect()
}
fn sequencer_initial_private_accounts(&self) -> Vec<PrivateAccountPublicInitialData> {
self.private_accounts
.iter()
.map(|(key_chain, account)| PrivateAccountPublicInitialData {
npk: key_chain.nullifier_public_key.clone(),
account: account.clone(),
})
.chain(self.private_accounts.iter().map(|(key_chain, account)| {
GenesisTransaction::SupplyPrivateAccount {
npk: key_chain.nullifier_public_key.clone(),
vpk: key_chain.viewing_public_key.clone(),
balance: account.balance,
}
}))
.collect()
}
}
@ -180,8 +176,7 @@ pub fn sequencer_config(
mempool_max_size,
block_create_timeout,
retry_pending_blocks_timeout: Duration::from_secs(5),
initial_public_accounts: Some(initial_data.sequencer_initial_public_accounts()),
initial_private_accounts: Some(initial_data.sequencer_initial_private_accounts()),
genesis: initial_data.sequencer_genesis(),
signing_key: [37; 32],
bedrock_config: BedrockConfig {
backoff: BackoffConfig {

View File

@ -13,7 +13,8 @@ use integration_tests::{
};
use key_protocol::key_management::{KeyChain, key_tree::chain_index::ChainIndex};
use log::info;
use nssa::{AccountId, program::Program};
use nssa::{AccountId, Data, program::Program};
use nssa_core::account::Nonce;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::{
@ -349,8 +350,8 @@ async fn import_private_account() -> Result<()> {
let account = nssa::Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 777,
data: Default::default(),
nonce: Default::default(),
data: Data::default(),
nonce: Nonce::default(),
};
let key_chain_json = serde_json::to_string(&key_chain)
@ -394,8 +395,8 @@ async fn import_private_account_second_time_overrides_account_data() -> Result<(
let initial_account = nssa::Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 100,
data: Default::default(),
nonce: Default::default(),
data: Data::default(),
nonce: Nonce::default(),
};
// First import
@ -411,8 +412,8 @@ async fn import_private_account_second_time_overrides_account_data() -> Result<(
let updated_account = nssa::Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 999,
data: Default::default(),
nonce: Default::default(),
data: Data::default(),
nonce: Nonce::default(),
};
// Second import with different account data (same key chain)

View File

@ -18,7 +18,6 @@ common.workspace = true
anyhow.workspace = true
serde.workspace = true
log.workspace = true
k256.workspace = true
sha2.workspace = true
rand.workspace = true

View File

@ -517,10 +517,13 @@ pub fn read_nssa_inputs<T: DeserializeOwned>() -> (ProgramInput<T>, InstructionD
/// - `pre_states`: The list of input accounts, each annotated with authorization metadata.
/// - `post_states`: The list of resulting accounts after executing the program logic.
/// - `executing_program_id`: The identifier of the program that was executed.
/// - `is_genesis`: When `true`, skips the total balance conservation check (rule 8), allowing
/// balance minting. Must only be `true` for genesis supply transactions.
pub fn validate_execution(
pre_states: &[AccountWithMetadata],
post_states: &[AccountPostState],
executing_program_id: ProgramId,
is_genesis: bool,
) -> Result<(), ExecutionValidationError> {
// 1. Check account ids are all different
if !validate_uniqueness_of_account_ids(pre_states) {
@ -588,25 +591,26 @@ pub fn validate_execution(
}
}
// 8. Total balance is preserved
// 8. Total balance is preserved (skipped for genesis supply transactions)
if !is_genesis {
let Some(total_balance_pre_states) =
WrappedBalanceSum::from_balances(pre_states.iter().map(|pre| pre.account.balance))
else {
return Err(ExecutionValidationError::BalanceSumOverflow);
};
let Some(total_balance_pre_states) =
WrappedBalanceSum::from_balances(pre_states.iter().map(|pre| pre.account.balance))
else {
return Err(ExecutionValidationError::BalanceSumOverflow);
};
let Some(total_balance_post_states) =
WrappedBalanceSum::from_balances(post_states.iter().map(|post| post.account.balance))
else {
return Err(ExecutionValidationError::BalanceSumOverflow);
};
let Some(total_balance_post_states) =
WrappedBalanceSum::from_balances(post_states.iter().map(|post| post.account.balance))
else {
return Err(ExecutionValidationError::BalanceSumOverflow);
};
if total_balance_pre_states != total_balance_post_states {
return Err(ExecutionValidationError::MismatchedTotalBalance {
total_balance_pre_states,
total_balance_post_states,
});
if total_balance_pre_states != total_balance_post_states {
return Err(ExecutionValidationError::MismatchedTotalBalance {
total_balance_pre_states,
total_balance_post_states,
});
}
}
Ok(())

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, PINATA_ELF,
PINATA_ID, TOKEN_ELF, TOKEN_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID,
GENESIS_SUPPLY_ACCOUNT_ELF, GENESIS_SUPPLY_ACCOUNT_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF,
TOKEN_ID,
},
};
@ -148,6 +149,14 @@ impl Program {
elf: ASSOCIATED_TOKEN_ACCOUNT_ELF.to_vec(),
}
}
#[must_use]
pub fn genesis_supply_account() -> Self {
Self {
id: GENESIS_SUPPLY_ACCOUNT_ID,
elf: GENESIS_SUPPLY_ACCOUNT_ELF.to_vec(),
}
}
}
// TODO: Testnet only. Refactor to prevent compilation on mainnet.

View File

@ -177,7 +177,7 @@ pub mod tests {
let witness_set = WitnessSet::for_message(&message, &[&key1, &key1]);
let tx = PublicTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0, false);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
@ -197,7 +197,7 @@ pub mod tests {
let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]);
let tx = PublicTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0, false);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
@ -218,7 +218,7 @@ pub mod tests {
let mut witness_set = WitnessSet::for_message(&message, &[&key1, &key2]);
witness_set.signatures_and_public_keys[0].0 = Signature::new_for_tests([1; 64]);
let tx = PublicTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0, false);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
@ -238,7 +238,7 @@ pub mod tests {
let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]);
let tx = PublicTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0, false);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
@ -254,7 +254,7 @@ pub mod tests {
let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]);
let tx = PublicTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0, false);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
}

View File

@ -176,6 +176,8 @@ impl V03State {
this.insert_program(Program::clock());
this.insert_clock_accounts(genesis_timestamp);
this.insert_program(Program::genesis_supply_account());
this.insert_program(Program::authenticated_transfer_program());
this.insert_program(Program::token());
this.insert_program(Program::amm());
@ -243,7 +245,8 @@ impl V03State {
block_id: BlockId,
timestamp: Timestamp,
) -> Result<(), NssaError> {
let diff = ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp)?;
let diff =
ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp, false)?;
self.apply_state_diff(diff);
Ok(())
}

View File

@ -44,6 +44,7 @@ impl ValidatedStateDiff {
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
is_genesis: bool,
) -> Result<Self, NssaError> {
let message = tx.message();
let witness_set = tx.witness_set();
@ -189,6 +190,7 @@ impl ValidatedStateDiff {
&program_output.pre_states,
&program_output.post_states,
chained_call.program_id,
is_genesis,
)
.map_err(InvalidProgramBehaviorError::ExecutionValidationFailed)?;
@ -215,9 +217,10 @@ impl ValidatedStateDiff {
match claim {
Claim::Authorized => {
// The program can only claim accounts that were authorized by the signer.
// The program can only claim accounts that were authorized by the signer if
// it's not genesis.
ensure!(
is_authorized(&account_id),
is_authorized(&account_id) || is_genesis,
InvalidProgramBehaviorError::ClaimedUnauthorizedAccount { account_id }
);
}

View File

@ -16,5 +16,6 @@ amm_core.workspace = true
amm_program.workspace = true
ata_core.workspace = true
ata_program.workspace = true
genesis_supply_account_core.workspace = true
risc0-zkvm.workspace = true
serde = { workspace = true, default-features = false }

View File

@ -8,7 +8,6 @@ use nssa_core::{
/// Initializes a default account under the ownership of this program.
fn initialize_account(pre_state: AccountWithMetadata) -> AccountPostState {
let account_to_claim = AccountPostState::new_claimed(pre_state.account, Claim::Authorized);
let is_authorized = pre_state.is_authorized;
// Continue only if the account to claim has default values
assert!(
@ -16,9 +15,6 @@ fn initialize_account(pre_state: AccountWithMetadata) -> AccountPostState {
"Account must be uninitialized"
);
// Continue only if the owner authorized this operation
assert!(is_authorized, "Account must be authorized");
account_to_claim
}

View File

@ -0,0 +1,129 @@
//! Genesis Supply Account Program.
//!
//! A genesis-only program that supplies initial balance to an account.
//! Uses the "initiate → callback" chained-call pattern to:
//! 1. Initialize the target account via `authenticated_transfer`
//! 2. Add the supplied balance in a self-callback
//!
//! This program verifies that the clock's `block_id` is 0, ensuring it can only
//! execute at genesis. The balance increase violates normal balance conservation,
//! so it requires `is_genesis: true` in the validation pipeline.
//!
//! # Accounts
//!
//! - `Initiate`: `[target_account, clock_account]`
//! - `Callback`: `[target_account, clock_account]`
use clock_core::{CLOCK_01_PROGRAM_ACCOUNT_ID, ClockAccountData};
use genesis_supply_account_core::Instruction;
use nssa_core::program::{
AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_nssa_inputs,
};
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction,
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
match instruction {
Instruction::Initiate {
balance,
authenticated_transfer_id,
} => {
let Ok([target_pre, clock_pre]) = <[_; 2]>::try_from(pre_states) else {
panic!("Initiate requires exactly 2 accounts: target, clock");
};
assert_eq!(
clock_pre.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
"Second account must be the clock account"
);
let clock_data =
ClockAccountData::from_bytes(&clock_pre.account.data.clone().into_inner());
assert_eq!(
clock_data.block_id, 0,
"Genesis supply can only execute at genesis (block_id must be 0)"
);
// Compute the expected post-state of the target after authenticated_transfer
// initializes it. authenticated_transfer claims the account (sets
// program_owner = auth_transfer_id), leaving balance at 0.
let mut target_after_init = target_pre.clone();
target_after_init.account.program_owner = authenticated_transfer_id;
// Chained call 1: authenticated_transfer(0) — initializes (claims) the target account.
let init_instruction =
risc0_zkvm::serde::to_vec(&0_u128).expect("init instruction serialization");
let call_1 = ChainedCall {
program_id: authenticated_transfer_id,
pre_states: vec![target_pre.clone()],
instruction_data: init_instruction,
pda_seeds: vec![],
};
// Chained call 2: self-callback to add the supplied balance.
let callback_instruction =
risc0_zkvm::serde::to_vec(&Instruction::Callback { balance })
.expect("callback instruction serialization");
let call_2 = ChainedCall {
program_id: self_program_id,
pre_states: vec![target_after_init, clock_pre.clone()],
instruction_data: callback_instruction,
pda_seeds: vec![],
};
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![target_pre.clone(), clock_pre.clone()],
// post_states match pre_states — mutations happen in chained calls
vec![
AccountPostState::new(target_pre.account),
AccountPostState::new(clock_pre.account),
],
)
.with_chained_calls(vec![call_1, call_2])
.write();
}
Instruction::Callback { balance } => {
// Access control: must be called from this program itself.
assert_eq!(
caller_program_id,
Some(self_program_id),
"Callback can only be invoked as a chained call from genesis_supply_account"
);
let Ok([target_pre, clock_pre]) = <[_; 2]>::try_from(pre_states) else {
panic!("Callback requires exactly 2 accounts: target, clock");
};
// Add the supplied balance to the target account.
let mut target_post_account = target_pre.account.clone();
target_post_account.balance = target_post_account
.balance
.checked_add(balance)
.expect("target balance overflow");
let target_post = AccountPostState::new(target_post_account);
let clock_post = AccountPostState::new(clock_pre.account.clone());
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![target_pre, clock_pre],
vec![target_post, clock_post],
)
.write();
}
}
}

View File

@ -3,6 +3,7 @@ use std::{
convert::Infallible,
};
use clock_core::{CLOCK_PROGRAM_ACCOUNT_IDS, ClockAccountData};
use nssa_core::{
Commitment, CommitmentSetDigest, DUMMY_COMMITMENT_HASH, EncryptionScheme, MembershipProof,
Nullifier, NullifierPublicKey, NullifierSecretKey, PrivacyPreservingCircuitInput,
@ -125,10 +126,22 @@ impl ExecutionState {
// Check that the program is well behaved.
// See the # Programs section for the definition of the `validate_execution` method.
//
// TODO: This looks hacky as hell.
// Derive is_genesis: true if any pre_state is a clock account at block 0.
// This allows the genesis supply account program to mint balance at genesis.
// Security: program execution is verified via env::verify(), and the genesis supply
// program itself panics if clock.block_id != 0, so is_genesis cannot be forged.
let is_genesis = program_output.pre_states.iter().any(|pre| {
CLOCK_PROGRAM_ACCOUNT_IDS.contains(&pre.account_id)
&& ClockAccountData::from_bytes(&pre.account.data.clone().into_inner()).block_id
== 0
});
let validated_execution = validate_execution(
&program_output.pre_states,
&program_output.post_states,
chained_call.program_id,
is_genesis,
);
if let Err(err) = validated_execution {
panic!(
@ -151,6 +164,7 @@ impl ExecutionState {
&authorized_pdas,
program_output.pre_states,
program_output.post_states,
is_genesis,
);
chain_calls_counter = chain_calls_counter.checked_add(1).expect(
"Chain calls counter should not overflow as it checked before incrementing",
@ -194,6 +208,7 @@ impl ExecutionState {
authorized_pdas: &HashSet<AccountId>,
pre_states: Vec<AccountWithMetadata>,
post_states: Vec<AccountPostState>,
is_genesis: bool,
) {
for (pre, mut post) in pre_states.into_iter().zip(post_states) {
let pre_account_id = pre.account_id;
@ -264,7 +279,7 @@ impl ExecutionState {
// Note: no need to check authorized pdas because we have already
// checked consistency of authorization above.
assert!(
pre_is_authorized,
pre_is_authorized || is_genesis,
"Cannot claim unauthorized account {pre_account_id}"
);
}

View File

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

View File

@ -0,0 +1,25 @@
//! Core data structures for the Genesis Supply Account Program.
use nssa_core::program::ProgramId;
use serde::{Deserialize, Serialize};
/// Instruction type for the Genesis Supply Account program.
#[derive(Serialize, Deserialize)]
pub enum Instruction {
/// External entrypoint: initialize the target account then supply it with `balance`.
///
/// Required accounts: `[target_account, clock_account]`.
///
/// Emits 2 chained calls:
/// 1. `authenticated_transfer(0)` on `[target_account]` — claims the account
/// 2. `Callback { balance }` on `[target_after_init, clock_account]` — adds balance
Initiate {
balance: u128,
authenticated_transfer_id: ProgramId,
},
/// Internal: add `balance` to the already-initialized target account.
///
/// Access control: only callable as a chained call from this program itself
/// (enforced via `caller_program_id == Some(self_program_id)`).
Callback { balance: u128 },
}

View File

@ -15,6 +15,8 @@ storage.workspace = true
mempool.workspace = true
bedrock_client.workspace = true
testnet_initial_state.workspace = true
genesis_supply_account_core.workspace = true
key_protocol.workspace = true
anyhow.workspace = true
serde.workspace = true

View File

@ -6,6 +6,7 @@ use common::{
block::{Block, BlockMeta, MantleMsgId},
transaction::NSSATransaction,
};
use log::info;
use nssa::V03State;
use storage::{error::DbError, sequencer::RocksDBIO};
@ -18,21 +19,48 @@ pub struct SequencerStore {
}
impl SequencerStore {
/// Open existing database at the given location. Fails if no database is found.
pub fn open_db(location: &Path, signing_key: nssa::PrivateKey) -> Result<Self> {
let dbio = RocksDBIO::open(location)?;
let genesis_id = dbio.get_meta_first_block_in_db()?;
let last_id = dbio.latest_block_meta()?.id;
info!("Preparing block cache");
let mut tx_hash_to_block_map = HashMap::new();
for i in genesis_id..=last_id {
let block = dbio
.get_block(i)?
.expect("Block should be present in the database");
tx_hash_to_block_map.extend(block_to_transactions_map(&block));
}
info!(
"Block cache prepared. Total blocks in cache: {}",
tx_hash_to_block_map.len()
);
Ok(Self {
dbio,
tx_hash_to_block_map,
genesis_id,
signing_key,
})
}
/// Starting database at the start of new chain.
/// Creates files if necessary.
///
/// ATTENTION: Will overwrite genesis block.
pub fn open_db_with_genesis(
pub fn create_db_with_genesis(
location: &Path,
genesis_block: &Block,
genesis_msg_id: MantleMsgId,
genesis_state: &V03State,
signing_key: nssa::PrivateKey,
) -> Result<Self> {
let tx_hash_to_block_map = block_to_transactions_map(genesis_block);
let dbio = RocksDBIO::open_or_create(location, genesis_block, genesis_msg_id)?;
let dbio = RocksDBIO::create(location, genesis_block, genesis_msg_id, genesis_state)?;
let genesis_id = dbio.get_meta_first_block_in_db()?;
let tx_hash_to_block_map = block_to_transactions_map(genesis_block);
Ok(Self {
dbio,
@ -100,8 +128,8 @@ impl SequencerStore {
Ok(())
}
pub fn get_nssa_state(&self) -> Option<V03State> {
self.dbio.get_nssa_state().ok()
pub fn get_nssa_state(&self) -> Result<V03State> {
self.dbio.get_nssa_state().map_err(Into::into)
}
}
@ -139,9 +167,14 @@ mod tests {
let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]);
// Start an empty node store
let mut node_store =
SequencerStore::open_db_with_genesis(path, &genesis_block, [0; 32], signing_key)
.unwrap();
let mut node_store = SequencerStore::create_db_with_genesis(
path,
&genesis_block,
[0; 32],
&testnet_initial_state::initial_state(),
signing_key,
)
.unwrap();
let tx = common::test_utils::produce_dummy_empty_transaction();
let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]);
@ -174,9 +207,14 @@ mod tests {
let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]);
let genesis_hash = genesis_block.header.hash;
let node_store =
SequencerStore::open_db_with_genesis(path, &genesis_block, [0; 32], signing_key)
.unwrap();
let node_store = SequencerStore::create_db_with_genesis(
path,
&genesis_block,
[0; 32],
&testnet_initial_state::initial_state(),
signing_key,
)
.unwrap();
// Verify that initially the latest block hash equals genesis hash
let latest_meta = node_store.latest_block_meta().unwrap();
@ -199,9 +237,14 @@ mod tests {
};
let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]);
let mut node_store =
SequencerStore::open_db_with_genesis(path, &genesis_block, [0; 32], signing_key)
.unwrap();
let mut node_store = SequencerStore::create_db_with_genesis(
path,
&genesis_block,
[0; 32],
&testnet_initial_state::initial_state(),
signing_key,
)
.unwrap();
// Add a new block
let tx = common::test_utils::produce_dummy_empty_transaction();
@ -235,9 +278,14 @@ mod tests {
};
let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]);
let mut node_store =
SequencerStore::open_db_with_genesis(path, &genesis_block, [0; 32], signing_key)
.unwrap();
let mut node_store = SequencerStore::create_db_with_genesis(
path,
&genesis_block,
[0; 32],
&testnet_initial_state::initial_state(),
signing_key,
)
.unwrap();
// Add a new block with Pending status
let tx = common::test_utils::produce_dummy_empty_transaction();
@ -264,4 +312,49 @@ mod tests {
common::block::BedrockStatus::Finalized
));
}
#[test]
fn open_existing_db_caches_transactions() {
let temp_dir = tempdir().unwrap();
let path = temp_dir.path();
let signing_key = sequencer_sign_key_for_testing();
let genesis_block_hashable_data = HashableBlockData {
block_id: 0,
prev_block_hash: HashType([0; 32]),
timestamp: 0,
transactions: vec![],
};
let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]);
let tx = common::test_utils::produce_dummy_empty_transaction();
{
// Create a scope to drop the first store after creating the db
let mut node_store = SequencerStore::create_db_with_genesis(
path,
&genesis_block,
[0; 32],
&testnet_initial_state::initial_state(),
signing_key.clone(),
)
.unwrap();
// Add a new block
let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]);
node_store
.update(
&block,
[1; 32],
&V03State::new_with_genesis_accounts(&[], vec![], 0),
)
.unwrap();
}
// Re-open the store and verify that the transaction is still retrievable (which means it
// was cached correctly)
let node_store = SequencerStore::open_db(path, signing_key).unwrap();
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some(tx), retrieved_tx);
}
}

View File

@ -11,10 +11,26 @@ use bytesize::ByteSize;
use common::config::BasicAuth;
use humantime_serde;
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use nssa::AccountId;
use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey};
use serde::{Deserialize, Serialize};
use testnet_initial_state::{PrivateAccountPublicInitialData, PublicAccountPublicInitialData};
use url::Url;
/// A transaction to be applied at genesis to supply initial balances.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GenesisTransaction {
SupplyPublicAccount {
account_id: AccountId,
balance: u128,
},
SupplyPrivateAccount {
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
balance: u128,
},
}
// TODO: Provide default values
#[derive(Clone, Serialize, Deserialize)]
pub struct SequencerConfig {
@ -44,10 +60,9 @@ pub struct SequencerConfig {
pub bedrock_config: BedrockConfig,
/// Indexer RPC URL.
pub indexer_rpc_url: Url,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_public_accounts: Option<Vec<PublicAccountPublicInitialData>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_private_accounts: Option<Vec<PrivateAccountPublicInitialData>>,
/// Genesis configuration.
#[serde(default)]
pub genesis: Vec<GenesisTransaction>,
}
#[derive(Clone, Serialize, Deserialize)]

View File

@ -1,23 +1,25 @@
use std::{path::Path, time::Instant};
use std::{collections::HashMap, path::Path, time::Instant};
use anyhow::{Context as _, Result, anyhow};
use bedrock_client::SignedMantleTx;
#[cfg(feature = "testnet")]
use common::PINATA_BASE58;
use common::{
HashType,
block::{BedrockStatus, Block, HashableBlockData},
transaction::{NSSATransaction, clock_invocation},
};
use config::SequencerConfig;
use config::{GenesisTransaction, SequencerConfig};
use log::{error, info, warn};
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use mempool::{MemPool, MemPoolHandle};
#[cfg(feature = "mock")]
pub use mock::SequencerCoreWithMockClients;
use nssa::V03State;
use nssa::{
Account, AccountId, PrivacyPreservingTransaction, PublicTransaction, ValidatedStateDiff,
privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program,
public_transaction::Message,
};
use nssa_core::account::AccountWithMetadata;
pub use storage::error::DbError;
use testnet_initial_state::initial_state;
use crate::{
block_settlement_client::{BlockSettlementClient, BlockSettlementClientTrait, MsgId},
@ -55,16 +57,8 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
pub async fn start_from_config(
config: SequencerConfig,
) -> (Self, MemPoolHandle<NSSATransaction>) {
let hashable_data = HashableBlockData {
block_id: config.genesis_id,
transactions: vec![],
prev_block_hash: HashType([0; 32]),
timestamp: 0,
};
let signing_key = nssa::PrivateKey::try_new(config.signing_key).unwrap();
let genesis_parent_msg_id = [0; 32];
let genesis_block = hashable_data.into_pending_block(&signing_key, genesis_parent_msg_id);
let db_path = config.home.join("rocksdb");
let bedrock_signing_key =
load_or_create_signing_key(&config.home.join("bedrock_signing_key"))
@ -77,79 +71,52 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
.await
.expect("Failed to create Indexer Client");
let (_tx, genesis_msg_id) = block_settlement_client
.create_inscribe_tx(&genesis_block)
.expect("Failed to create inscribe tx for genesis block");
let (store, state) = SequencerStore::open_db(&db_path, signing_key.clone()).map_or_else(
|err| {
warn!(
"Failed to open existing database at {} with error: {err:#?}. Starting from genesis.",
db_path.display()
);
let genesis_parent_msg_id = [0; 32];
let (genesis_state, genesis_txs) = build_genesis_state(&config);
let hashable_data = HashableBlockData {
block_id: config.genesis_id,
transactions: genesis_txs,
prev_block_hash: HashType([0; 32]),
timestamp: 0,
};
let genesis_block =
hashable_data.into_pending_block(&signing_key, genesis_parent_msg_id);
let (_tx, genesis_msg_id) = block_settlement_client
.create_inscribe_tx(&genesis_block)
.expect("Failed to create inscribe tx for genesis block");
let store = SequencerStore::create_db_with_genesis(
&db_path,
&genesis_block,
genesis_msg_id.into(),
&genesis_state,
signing_key,
)
.expect("Failed to create database with genesis block");
(store, genesis_state)
},
|store| {
let state = store
.get_nssa_state()
.expect("Failed to read state from store");
(store, state)
}
);
// Sequencer should panic if unable to open db,
// as fixing this issue may require actions non-native to program scope
let store = SequencerStore::open_db_with_genesis(
&config.home.join("rocksdb"),
&genesis_block,
genesis_msg_id.into(),
signing_key,
)
.unwrap();
let latest_block_meta = store
.latest_block_meta()
.expect("Failed to read latest block meta from store");
#[cfg_attr(not(feature = "testnet"), allow(unused_mut))]
let mut state = if let Some(state) = store.get_nssa_state() {
info!("Found local database. Loading state and pending blocks from it.");
state
} else {
info!(
"No database found when starting the sequencer. Creating a fresh new with the initial data"
);
let initial_private_accounts: Option<
Vec<(nssa_core::Commitment, nssa_core::Nullifier)>,
> = config.initial_private_accounts.clone().map(|accounts| {
accounts
.iter()
.map(|init_comm_data| {
let npk = &init_comm_data.npk;
let mut acc = init_comm_data.account.clone();
acc.program_owner =
nssa::program::Program::authenticated_transfer_program().id();
(
nssa_core::Commitment::new(npk, &acc),
nssa_core::Nullifier::for_account_initialization(npk),
)
})
.collect()
});
let init_accs: Option<Vec<(nssa::AccountId, u128)>> = config
.initial_public_accounts
.clone()
.map(|initial_accounts| {
initial_accounts
.iter()
.map(|acc_data| (acc_data.account_id, acc_data.balance))
.collect()
});
// If initial commitments or accounts are present in config, need to construct state
// from them
if initial_private_accounts.is_some() || init_accs.is_some() {
V03State::new_with_genesis_accounts(
&init_accs.unwrap_or_default(),
initial_private_accounts.unwrap_or_default(),
genesis_block.header.timestamp,
)
} else {
initial_state()
}
};
#[cfg(feature = "testnet")]
state.add_pinata_program(PINATA_BASE58.parse().unwrap());
let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size);
let sequencer_core = Self {
@ -362,6 +329,134 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
}
}
/// Builds the initial genesis state from `testnet_initial_state` plus configured genesis
/// transactions. Returns the final state and the list of [`NSSATransaction`]s that should be
/// committed to the genesis block so external observers can replay them.
fn build_genesis_state(config: &SequencerConfig) -> (nssa::V03State, Vec<NSSATransaction>) {
#[cfg(not(feature = "testnet"))]
let mut state = testnet_initial_state::initial_state();
#[cfg(feature = "testnet")]
let mut state = testnet_initial_state::initial_state_testnet();
let genesis_txs = config
.genesis
.iter()
.map(|genesis_tx| {
let (tx, diff) = match genesis_tx {
GenesisTransaction::SupplyPublicAccount {
account_id,
balance,
} => build_public_genesis_transaction(config, &state, account_id, *balance),
GenesisTransaction::SupplyPrivateAccount { npk, vpk, balance } => {
build_private_genesis_transaction(
config,
&state,
npk.clone(),
vpk.clone(),
*balance,
)
}
};
state.apply_state_diff(diff);
tx
})
.collect();
(state, genesis_txs)
}
fn build_public_genesis_transaction(
config: &SequencerConfig,
state: &nssa::V03State,
account_id: &AccountId,
balance: u128,
) -> (NSSATransaction, ValidatedStateDiff) {
let authenticated_transfer_id = Program::authenticated_transfer_program().id();
let genesis_supply_id = nssa::program::Program::genesis_supply_account().id();
let message = Message::try_new(
genesis_supply_id,
vec![*account_id, nssa::CLOCK_01_PROGRAM_ACCOUNT_ID],
vec![],
genesis_supply_account_core::Instruction::Initiate {
balance,
authenticated_transfer_id,
},
)
.expect("Failed to serialize genesis supply instruction");
let witness_set = nssa::public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let diff = ValidatedStateDiff::from_public_transaction(&tx, state, config.genesis_id, 0, true)
.expect("Failed to execute genesis supply public transaction");
(tx.into(), diff)
}
fn build_private_genesis_transaction(
config: &SequencerConfig,
state: &nssa::V03State,
npk: nssa_core::NullifierPublicKey,
vpk: nssa_core::encryption::ViewingPublicKey,
balance: u128,
) -> (NSSATransaction, ValidatedStateDiff) {
let authenticated_transfer = Program::authenticated_transfer_program();
let account_id = AccountId::from(&npk);
let clock_account = state.get_account_by_id(nssa::CLOCK_01_PROGRAM_ACCOUNT_ID);
let pre_states = vec![
AccountWithMetadata::new(Account::default(), false, account_id),
AccountWithMetadata::new(clock_account, false, nssa::CLOCK_01_PROGRAM_ACCOUNT_ID),
];
let instruction = nssa::program::Program::serialize_instruction(
genesis_supply_account_core::Instruction::Initiate {
balance,
authenticated_transfer_id: authenticated_transfer.id(),
},
)
.expect("Failed to serialize genesis supply private account instruction");
let eph_holder =
key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder::new(&npk);
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
let genesis_supply_account = Program::genesis_supply_account();
let (output, proof) = nssa::privacy_preserving_transaction::circuit::execute_and_prove(
pre_states,
instruction,
vec![2, 0],
vec![(npk.clone(), ssk)],
vec![],
vec![None],
&ProgramWithDependencies::new(
genesis_supply_account.clone(),
HashMap::from([
(authenticated_transfer.id(), authenticated_transfer),
(genesis_supply_account.id(), genesis_supply_account),
]),
),
)
.expect("Failed to execute and prove genesis supply private account transaction");
let message = nssa::privacy_preserving_transaction::Message::try_from_circuit_output(
vec![nssa::CLOCK_01_PROGRAM_ACCOUNT_ID],
vec![],
vec![(npk, vpk, epk)],
output,
)
.expect("Failed to serialize genesis supply private account instruction");
let witness_set =
nssa::privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let diff =
ValidatedStateDiff::from_privacy_preserving_transaction(&tx, state, config.genesis_id, 0)
.expect("Failed to validate genesis supply private account transaction");
(tx.into(), diff)
}
/// 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() {
@ -401,7 +496,7 @@ mod tests {
use testnet_initial_state::{initial_accounts, initial_pub_accounts_private_keys};
use crate::{
config::{BedrockConfig, SequencerConfig},
config::{BedrockConfig, GenesisTransaction, SequencerConfig},
mock::SequencerCoreWithMockClients,
};
@ -429,8 +524,7 @@ mod tests {
},
retry_pending_blocks_timeout: Duration::from_mins(4),
indexer_rpc_url: "ws://localhost:8779".parse().unwrap(),
initial_public_accounts: None,
initial_private_accounts: None,
genesis: vec![],
}
}
@ -1079,24 +1173,21 @@ mod tests {
account::AccountWithMetadata,
encryption::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey},
};
use testnet_initial_state::PrivateAccountPublicInitialData;
let nsk: nssa_core::NullifierSecretKey = [7; 32];
let npk = nssa_core::NullifierPublicKey::from(&nsk);
let vsk: EphemeralSecretKey = [8; 32];
let vpk = ViewingPublicKey::from_scalar(vsk);
let genesis_account = Account {
program_owner: Program::authenticated_transfer_program().id(),
..Account::default()
};
// Start a sequencer from config with a preconfigured private genesis account
let mut config = setup_sequencer_config();
config.initial_private_accounts = Some(vec![PrivateAccountPublicInitialData {
npk: npk.clone(),
account: genesis_account,
}]);
config
.genesis
.push(GenesisTransaction::SupplyPrivateAccount {
npk: npk.clone(),
vpk: vpk.clone(),
balance: 10,
});
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;

View File

@ -40,36 +40,26 @@ impl DBIO for RocksDBIO {
}
impl RocksDBIO {
pub fn open_or_create(
pub fn open(path: &Path) -> DbResult<Self> {
let db_opts = Options::default();
Self::open_inner(path, &db_opts)
}
pub fn create(
path: &Path,
genesis_block: &Block,
genesis_msg_id: MantleMsgId,
genesis_state: &V03State,
) -> DbResult<Self> {
let mut cf_opts = Options::default();
cf_opts.set_max_write_buffer_number(16);
// ToDo: Add more column families for different data
let cfb = ColumnFamilyDescriptor::new(CF_BLOCK_NAME, cf_opts.clone());
let cfmeta = ColumnFamilyDescriptor::new(CF_META_NAME, cf_opts.clone());
let cfstate = ColumnFamilyDescriptor::new(CF_NSSA_STATE_NAME, cf_opts.clone());
let mut db_opts = Options::default();
db_opts.create_missing_column_families(true);
db_opts.create_if_missing(true);
let db = DBWithThreadMode::<MultiThreaded>::open_cf_descriptors(
&db_opts,
path,
vec![cfb, cfmeta, cfstate],
)
.map_err(|err| DbError::RocksDbError {
error: err,
additional_info: Some("Failed to open or create DB".to_owned()),
})?;
let dbio = Self { db };
let dbio = Self::open_inner(path, &db_opts)?;
let is_start_set = dbio.get_meta_is_first_block_set()?;
if !is_start_set {
let block_id = genesis_block.header.block_id;
// TODO: Shouldn't this be atomic (batched)?
dbio.put_meta_first_block_in_db(genesis_block, genesis_msg_id)?;
dbio.put_meta_is_first_block_set()?;
dbio.put_meta_last_block_in_db(block_id)?;
@ -79,11 +69,35 @@ impl RocksDBIO {
hash: genesis_block.header.hash,
msg_id: genesis_msg_id,
})?;
dbio.put_nssa_state_in_db(genesis_state)?;
}
Ok(dbio)
}
fn open_inner(path: &Path, db_opts: &Options) -> DbResult<Self> {
let mut cf_opts = Options::default();
cf_opts.set_max_write_buffer_number(16);
// ToDo: Add more column families for different data
let cfb = ColumnFamilyDescriptor::new(CF_BLOCK_NAME, cf_opts.clone());
let cfmeta = ColumnFamilyDescriptor::new(CF_META_NAME, cf_opts.clone());
let cfstate = ColumnFamilyDescriptor::new(CF_NSSA_STATE_NAME, cf_opts.clone());
let db = DBWithThreadMode::<MultiThreaded>::open_cf_descriptors(
db_opts,
path,
vec![cfb, cfmeta, cfstate],
)
.map_err(|err| DbError::RocksDbError {
error: err,
additional_info: Some("Failed to open or create DB".to_owned()),
})?;
let dbio = Self { db };
Ok(dbio)
}
pub fn destroy(path: &Path) -> DbResult<()> {
let mut cf_opts = Options::default();
cf_opts.set_max_write_buffer_number(16);
@ -133,7 +147,15 @@ impl RocksDBIO {
Ok(self.get_opt::<FirstBlockSetCell>(())?.is_some())
}
pub fn put_nssa_state_in_db(&self, state: &V03State, batch: &mut WriteBatch) -> DbResult<()> {
pub fn put_nssa_state_in_db(&self, state: &V03State) -> DbResult<()> {
self.put(&NSSAStateCellRef(state), ())
}
pub fn put_nssa_state_in_db_batch(
&self,
state: &V03State,
batch: &mut WriteBatch,
) -> DbResult<()> {
self.put_batch(&NSSAStateCellRef(state), (), batch)
}
@ -338,7 +360,7 @@ impl RocksDBIO {
let block_id = block.header.block_id;
let mut batch = WriteBatch::default();
self.put_block(block, msg_id, false, &mut batch)?;
self.put_nssa_state_in_db(state, &mut batch)?;
self.put_nssa_state_in_db_batch(state, &mut batch)?;
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,