Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ log.workspace = true
hex.workspace = true
borsh.workspace = true
logos-blockchain-common-http-client.workspace = true
jsonrpsee = { workspace = true }

[dev-dependencies]
serde_json.workspace = true
3 changes: 3 additions & 0 deletions common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ pub mod block;
mod borsh_base64;
pub mod config;
pub mod transaction;
pub mod receipt;
pub mod simulation;
pub mod snapshot;

// Module for tests utility functions
// TODO: Compile only for tests
Expand Down
53 changes: 53 additions & 0 deletions common/src/receipt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use borsh::{BorshDeserialize, BorshSerialize};
use serde::{Deserialize, Serialize};

use crate::HashType;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub enum TxStatus {
Pending,
Included { block_id: u64 },
Rejected { reason: String },
Unknown,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxReceipt {
pub tx_hash: HashType,
pub status: TxStatus,
pub timestamp_ms: Option<u64>,
}

#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct RejectedTxRecord {
pub reason: String,
pub timestamp_ms: u64,
pub block_height: u64,
}

#[cfg(test)]
mod tests {
#[test]
fn rejected_tx_record_borsh_roundtrip() {
use super::RejectedTxRecord;
let record = RejectedTxRecord {
reason: "nonce mismatch".to_owned(),
timestamp_ms: 1_700_000_000_000,
block_height: 42,
};
let encoded = borsh::to_vec(&record).unwrap();
let decoded: RejectedTxRecord = borsh::from_slice(&encoded).unwrap();
assert_eq!(record.reason, decoded.reason);
assert_eq!(record.timestamp_ms, decoded.timestamp_ms);
assert_eq!(record.block_height, decoded.block_height);
}

#[test]
fn tx_status_serde_roundtrip() {
use super::TxStatus;
let status = TxStatus::Rejected { reason: "bad sig".to_owned() };
let json = serde_json::to_string(&status).unwrap();
let back: TxStatus = serde_json::from_str(&json).unwrap();
assert!(matches!(back, TxStatus::Rejected { .. }));
}
}
31 changes: 31 additions & 0 deletions common/src/simulation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use nssa::{Account, AccountId};
use nssa_core::{Commitment, Nullifier};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationResult {
pub success: bool,
pub error: Option<String>,
pub accounts_modified: Vec<(AccountId, Account)>,
pub nullifiers_created: Vec<Nullifier>,
pub commitments_created: Vec<Commitment>,
}

#[cfg(test)]
mod tests {
#[test]
fn simulation_result_serde_roundtrip() {
use super::SimulationResult;
let result = SimulationResult {
success: true,
error: None,
accounts_modified: vec![],
nullifiers_created: vec![],
commitments_created: vec![],
};
let json = serde_json::to_string(&result).unwrap();
let back: SimulationResult = serde_json::from_str(&json).unwrap();
assert!(back.success);
assert!(back.error.is_none());
}
}
39 changes: 39 additions & 0 deletions common/src/snapshot.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use borsh::{BorshDeserialize, BorshSerialize};
use nssa_core::BlockId;
use serde::{Deserialize, Serialize};

/// A point-in-time snapshot of the sequencer's execution state, returned by the
/// `get_state_snapshot` RPC method and consumed by fork-mode startup.
///
/// `state_bytes` is an opaque Borsh-serialized `V03State`; callers that need to
/// deserialize it must depend on `nssa` directly.
#[derive(Debug, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct StateSnapshot {
/// Borsh-serialized `V03State` — opaque to keep `common` independent of `nssa`.
pub state_bytes: Vec<u8>,
/// Chain height at the moment the snapshot was taken.
pub block_id: BlockId,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn borsh_roundtrip() {
let snapshot = StateSnapshot { state_bytes: vec![1, 2, 3, 4], block_id: 42 };
let encoded = borsh::to_vec(&snapshot).unwrap();
let decoded: StateSnapshot = borsh::from_slice(&encoded).unwrap();
assert_eq!(decoded.block_id, 42);
assert_eq!(decoded.state_bytes, [1, 2, 3, 4]);
}

#[test]
fn serde_roundtrip() {
let snapshot = StateSnapshot { state_bytes: vec![0xff, 0x00], block_id: 7 };
let json = serde_json::to_string(&snapshot).unwrap();
let decoded: StateSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.block_id, 7);
assert_eq!(decoded.state_bytes, [0xff, 0x00]);
}
}
36 changes: 36 additions & 0 deletions common/src/transaction.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use borsh::{BorshDeserialize, BorshSerialize};
use jsonrpsee::types::{ErrorCode, ErrorObjectOwned};
use log::warn;
use nssa::{AccountId, V03State, ValidatedStateDiff};
use nssa_core::{BlockId, Timestamp};
Expand Down Expand Up @@ -154,6 +155,12 @@ pub enum TransactionMalformationError {
TransactionTooLarge { size: usize, max: usize },
}

impl From<TransactionMalformationError> for ErrorObjectOwned {
fn from(err: TransactionMalformationError) -> Self {
ErrorObjectOwned::owned(ErrorCode::InvalidParams.code(), err.to_string(), None::<()>)
}
}

/// Returns the canonical Clock Program invocation transaction for the given block timestamp.
/// Every valid block must end with exactly one occurrence of this transaction.
#[must_use]
Expand All @@ -170,3 +177,32 @@ pub fn clock_invocation(timestamp: clock_core::Instruction) -> nssa::PublicTrans
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}

#[cfg(test)]
mod malformation_error_tests {
use jsonrpsee::types::ErrorCode;

use super::*;

#[test]
fn from_too_large_produces_invalid_params_code() {
let err = TransactionMalformationError::TransactionTooLarge { size: 100, max: 50 };
let rpc_err: jsonrpsee::types::ErrorObjectOwned = err.into();
assert_eq!(rpc_err.code(), ErrorCode::InvalidParams.code());
assert!(rpc_err.message().contains("exceeds maximum"));
}

#[test]
fn from_failed_decode_produces_invalid_params_code() {
let err = TransactionMalformationError::FailedToDecode { tx: crate::HashType([0; 32]) };
let rpc_err: jsonrpsee::types::ErrorObjectOwned = err.into();
assert_eq!(rpc_err.code(), ErrorCode::InvalidParams.code());
}

#[test]
fn from_invalid_signature_produces_invalid_params_code() {
let err = TransactionMalformationError::InvalidSignature;
let rpc_err: jsonrpsee::types::ErrorObjectOwned = err.into();
assert_eq!(rpc_err.code(), ErrorCode::InvalidParams.code());
}
}
12 changes: 12 additions & 0 deletions nssa/src/validated_state_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,18 @@ impl ValidatedStateDiff {
self.0.public_diff.clone()
}

/// Returns the new nullifiers produced by this transaction.
#[must_use]
pub fn new_nullifiers(&self) -> &[nssa_core::Nullifier] {
&self.0.new_nullifiers
}

/// Returns the new commitments produced by this transaction.
#[must_use]
pub fn new_commitments(&self) -> &[nssa_core::Commitment] {
&self.0.new_commitments
}

pub(crate) fn into_state_diff(self) -> StateDiff {
self.0
}
Expand Down
87 changes: 87 additions & 0 deletions sequencer/core/src/block_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,27 @@ impl SequencerStore {
pub fn get_nssa_state(&self) -> Option<V03State> {
self.dbio.get_nssa_state().ok()
}

pub fn store_rejected_tx(
&mut self,
hash: common::HashType,
reason: String,
block_height: u64,
timestamp_ms: u64,
) -> anyhow::Result<()> {
use common::receipt::RejectedTxRecord;
let record = RejectedTxRecord { reason, timestamp_ms, block_height };
Ok(self.dbio.put_rejected_tx(hash, &record)?)
}

pub fn get_rejected_tx(&self, hash: common::HashType) -> Option<common::receipt::RejectedTxRecord> {
self.dbio.get_rejected_tx(hash).ok().flatten()
}

/// Returns the block_id that contains this transaction, or `None` if not yet included.
pub fn get_block_id_for_tx(&self, hash: common::HashType) -> Option<u64> {
self.tx_hash_to_block_map.get(&hash).copied()
}
}

pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64> {
Expand Down Expand Up @@ -264,4 +285,70 @@ mod tests {
common::block::BedrockStatus::Finalized
));
}

#[test]
fn store_and_get_rejected_tx() {
let temp_dir = tempdir().unwrap();
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 mut store =
SequencerStore::open_db_with_genesis(temp_dir.path(), &genesis_block, [0; 32], signing_key)
.unwrap();

let hash = HashType([42; 32]);
store
.store_rejected_tx(hash, "bad nonce".to_owned(), 1, 1_000_000)
.unwrap();

let record = store.get_rejected_tx(hash).unwrap();
assert_eq!(record.reason, "bad nonce");
assert_eq!(record.block_height, 1);
}

#[test]
fn get_block_id_for_tx_returns_none_when_not_included() {
let temp_dir = tempdir().unwrap();
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 store =
SequencerStore::open_db_with_genesis(temp_dir.path(), &genesis_block, [0; 32], signing_key)
.unwrap();

assert!(store.get_block_id_for_tx(HashType([1; 32])).is_none());
}

#[test]
fn get_block_id_for_tx_returns_block_id_after_inclusion() {
let temp_dir = tempdir().unwrap();
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 mut store =
SequencerStore::open_db_with_genesis(temp_dir.path(), &genesis_block, [0; 32], 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()]);
let dummy_state = nssa::V03State::new_with_genesis_accounts(&[], vec![], 0);
store.update(&block, [1; 32], &dummy_state).unwrap();

assert_eq!(store.get_block_id_for_tx(tx.hash()), Some(1));
}
}
6 changes: 6 additions & 0 deletions sequencer/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use bytesize::ByteSize;
use common::config::BasicAuth;
use humantime_serde;
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use nssa::V03State;
use serde::{Deserialize, Serialize};
use testnet_initial_state::{PrivateAccountPublicInitialData, PublicAccountPublicInitialData};
use url::Url;
Expand Down Expand Up @@ -48,6 +49,11 @@ pub struct SequencerConfig {
pub initial_public_accounts: Option<Vec<PublicAccountPublicInitialData>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_private_accounts: Option<Vec<PrivateAccountPublicInitialData>>,
/// Injected programmatically for fork mode; never read from or written to config files.
/// When `Some`, this state is used directly as the initial state, bypassing genesis and
/// `initial_public_accounts`/`initial_private_accounts`.
#[serde(skip)]
pub override_initial_state: Option<Box<V03State>>,
}

#[derive(Clone, Serialize, Deserialize)]
Expand Down
Loading