Skip to content

Commit 22e2061

Browse files
authored
feat(validator): enforce blockchain continuity (#1774)
Validator now only signs a new block if it chains from the previous one.
1 parent 023e01f commit 22e2061

9 files changed

Lines changed: 236 additions & 35 deletions

File tree

bin/node/src/commands/bundled.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ impl BundledCommand {
103103
crate::commands::validator::ValidatorCommand::bootstrap_genesis(
104104
&data_directory,
105105
&accounts_directory,
106+
&data_directory,
106107
genesis_config_file.as_ref(),
107108
validator_key,
108109
)

bin/node/src/commands/validator.rs

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,18 @@ pub enum ValidatorCommand {
3131
/// Bootstraps the genesis block.
3232
///
3333
/// Creates accounts from the genesis configuration, builds and signs the genesis block,
34-
/// and writes the signed block and account secret files to disk.
34+
/// and writes the signed block and account secret files to disk. Also initializes the
35+
/// validator's database with the genesis block as the chain tip.
3536
Bootstrap {
3637
/// Directory in which to write the genesis block file.
3738
#[arg(long, value_name = "DIR")]
3839
genesis_block_directory: PathBuf,
3940
/// Directory to write the account secret files (.mac) to.
4041
#[arg(long, value_name = "DIR")]
4142
accounts_directory: PathBuf,
43+
/// Directory in which to store the validator's database.
44+
#[arg(long, env = ENV_DATA_DIRECTORY, value_name = "DIR")]
45+
data_directory: PathBuf,
4246
/// Use the given configuration file to construct the genesis state from.
4347
#[arg(long, env = ENV_GENESIS_CONFIG_FILE, value_name = "GENESIS_CONFIG")]
4448
genesis_config_file: Option<PathBuf>,
@@ -101,12 +105,14 @@ impl ValidatorCommand {
101105
Self::Bootstrap {
102106
genesis_block_directory,
103107
accounts_directory,
108+
data_directory,
104109
genesis_config_file,
105110
validator_key,
106111
} => {
107112
Self::bootstrap_genesis(
108113
&genesis_block_directory,
109114
&accounts_directory,
115+
&data_directory,
110116
genesis_config_file.as_ref(),
111117
validator_key,
112118
)
@@ -169,6 +175,7 @@ impl ValidatorCommand {
169175
pub async fn bootstrap_genesis(
170176
genesis_block_directory: &Path,
171177
accounts_directory: &Path,
178+
data_directory: &Path,
172179
genesis_config: Option<&PathBuf>,
173180
validator_key: ValidatorKey,
174181
) -> anyhow::Result<()> {
@@ -191,24 +198,37 @@ impl ValidatorCommand {
191198
let signer = validator_key.into_signer().await?;
192199
match signer {
193200
ValidatorSigner::Kms(signer) => {
194-
build_and_write_genesis(config, signer, accounts_directory, genesis_block_directory)
195-
.await
201+
build_and_write_genesis(
202+
config,
203+
signer,
204+
accounts_directory,
205+
genesis_block_directory,
206+
data_directory,
207+
)
208+
.await
196209
},
197210
ValidatorSigner::Local(signer) => {
198-
build_and_write_genesis(config, signer, accounts_directory, genesis_block_directory)
199-
.await
211+
build_and_write_genesis(
212+
config,
213+
signer,
214+
accounts_directory,
215+
genesis_block_directory,
216+
data_directory,
217+
)
218+
.await
200219
},
201220
}
202221
}
203222
}
204223

205-
/// Builds the genesis state, writes account secret files, signs the genesis block, and writes it
206-
/// to disk.
224+
/// Builds the genesis state, writes account secret files, signs the genesis block, writes it
225+
/// to disk, and initializes the validator's database with the genesis block as the chain tip.
207226
async fn build_and_write_genesis(
208227
config: GenesisConfig,
209228
signer: impl BlockSigner,
210229
accounts_directory: &Path,
211230
genesis_block_directory: &Path,
231+
data_directory: &Path,
212232
) -> anyhow::Result<()> {
213233
// Build genesis state with the provided signer.
214234
let (genesis_state, secrets) = config.into_state(signer)?;
@@ -235,5 +255,16 @@ async fn build_and_write_genesis(
235255
let genesis_block_path = genesis_block_directory.join(GENESIS_BLOCK_FILENAME);
236256
fs_err::write(&genesis_block_path, block_bytes).context("failed to write genesis block")?;
237257

258+
// Initialize the validator database and persist the genesis block header as the chain tip.
259+
let (genesis_header, ..) = genesis_block.into_inner().into_parts();
260+
let db = miden_node_validator::db::load(data_directory.join("validator.sqlite3"))
261+
.await
262+
.context("failed to initialize validator database during bootstrap")?;
263+
db.transact("upsert_block_header", move |conn| {
264+
miden_node_validator::db::upsert_block_header(conn, &genesis_header)
265+
})
266+
.await
267+
.context("failed to persist genesis block header as chain tip")?;
268+
238269
Ok(())
239270
}

crates/validator/src/block_validation/mod.rs

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use miden_node_db::{DatabaseError, Db};
2-
use miden_protocol::block::ProposedBlock;
2+
use miden_node_utils::tracing::OpenTelemetrySpanExt;
3+
use miden_protocol::block::{BlockHeader, BlockNumber, ProposedBlock};
34
use miden_protocol::crypto::dsa::ecdsa_k256_keccak::Signature;
45
use miden_protocol::errors::ProposedBlockError;
56
use miden_protocol::transaction::{TransactionHeader, TransactionId};
6-
use tracing::{info_span, instrument};
7+
use tracing::{Span, instrument};
78

8-
use crate::db::find_unvalidated_transactions;
9+
use crate::db::{find_unvalidated_transactions, load_block_header};
910
use crate::{COMPONENT, ValidatorSigner};
1011

1112
// BLOCK VALIDATION ERROR
@@ -21,19 +22,35 @@ pub enum BlockValidationError {
2122
BlockSigningFailed(String),
2223
#[error("failed to select transactions")]
2324
DatabaseError(#[source] DatabaseError),
25+
#[error("block number mismatch: expected {expected}, got {actual}")]
26+
BlockNumberMismatch {
27+
expected: BlockNumber,
28+
actual: BlockNumber,
29+
},
30+
#[error("previous block commitment does not match chain tip")]
31+
PrevBlockCommitmentMismatch,
32+
#[error("no previous block header available for chain tip overwrite")]
33+
NoPrevBlockHeader,
2434
}
2535

2636
// BLOCK VALIDATION
2737
// ================================================================================================
2838

29-
/// Validates a block by checking that all transactions in the proposed block have been processed by
30-
/// the validator in the past.
31-
#[instrument(target = COMPONENT, skip_all, err)]
39+
/// Validates a proposed block by checking:
40+
/// 1. All transactions have been previously validated by this validator.
41+
/// 2. The block header can be successfully built from the proposed block.
42+
/// 3. The block is either: a. The valid next block in the chain (sequential block number, matching
43+
/// previous block commitment), or b. A replacement block at the same height as the current chain
44+
/// tip, validated against the previous block header.
45+
///
46+
/// On success, returns the signature and the validated block header.
47+
#[instrument(target = COMPONENT, skip_all, err, fields(tip.number = chain_tip.block_num().as_u32()))]
3248
pub async fn validate_block(
3349
proposed_block: ProposedBlock,
3450
signer: &ValidatorSigner,
3551
db: &Db,
36-
) -> Result<Signature, BlockValidationError> {
52+
chain_tip: BlockHeader,
53+
) -> Result<(Signature, BlockHeader), BlockValidationError> {
3754
// Search for any proposed transactions that have not previously been validated.
3855
let proposed_tx_ids =
3956
proposed_block.transactions().map(TransactionHeader::id).collect::<Vec<_>>();
@@ -50,15 +67,55 @@ pub async fn validate_block(
5067
}
5168

5269
// Build the block header.
53-
let (header, _) = proposed_block
70+
let (proposed_header, _) = proposed_block
5471
.into_header_and_body()
5572
.map_err(BlockValidationError::BlockBuildingFailed)?;
5673

57-
// Sign the header.
58-
let signature = info_span!("sign_block")
59-
.in_scope(async move || signer.sign(&header).await)
60-
.await
61-
.map_err(|err| BlockValidationError::BlockSigningFailed(err.to_string()))?;
74+
let span = Span::current();
75+
span.set_attribute("block.number", proposed_header.block_num().as_u32());
76+
span.set_attribute("block.commitment", proposed_header.commitment());
77+
78+
// If the proposed block has the same block number as the current chain tip, this is a
79+
// replacement block. Validate it against the previous block header.
80+
let prev = if proposed_header.block_num() == chain_tip.block_num() {
81+
let prev_block_num =
82+
chain_tip.block_num().parent().ok_or(BlockValidationError::NoPrevBlockHeader)?;
83+
db.query("load_block_header", move |conn| load_block_header(conn, prev_block_num))
84+
.await
85+
.map_err(BlockValidationError::DatabaseError)?
86+
.ok_or(BlockValidationError::NoPrevBlockHeader)?
87+
} else {
88+
// Proposed block is a new block.
89+
// Block number must be sequential.
90+
let expected_block_num = chain_tip.block_num().child();
91+
if proposed_header.block_num() != expected_block_num {
92+
return Err(BlockValidationError::BlockNumberMismatch {
93+
expected: expected_block_num,
94+
actual: proposed_header.block_num(),
95+
});
96+
}
97+
// Current chain tip is the parent of the proposed block.
98+
chain_tip
99+
};
100+
101+
// The proposed block's parent must match the block that the Validator has determined is its
102+
// parent (either chain tip or parent of chain tip).
103+
if proposed_header.prev_block_commitment() != prev.commitment() {
104+
return Err(BlockValidationError::PrevBlockCommitmentMismatch);
105+
}
62106

63-
Ok(signature)
107+
let signature = sign_header(signer, &proposed_header).await?;
108+
Ok((signature, proposed_header))
109+
}
110+
111+
/// Signs a block header using the validator's signer.
112+
#[instrument(target = COMPONENT, name = "sign_block", skip_all, err, fields(block.number = header.block_num().as_u32()))]
113+
async fn sign_header(
114+
signer: &ValidatorSigner,
115+
header: &BlockHeader,
116+
) -> Result<Signature, BlockValidationError> {
117+
signer
118+
.sign(header)
119+
.await
120+
.map_err(|err| BlockValidationError::BlockSigningFailed(err.to_string()))
64121
}

crates/validator/src/db/migrations/2025062000000_setup/up.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ CREATE TABLE validated_transactions (
1313

1414
CREATE INDEX idx_validated_transactions_account_id ON validated_transactions(account_id);
1515
CREATE INDEX idx_validated_transactions_block_num ON validated_transactions(block_num);
16+
17+
CREATE TABLE block_headers (
18+
block_num INTEGER PRIMARY KEY,
19+
block_header BLOB NOT NULL
20+
) WITHOUT ROWID;

crates/validator/src/db/mod.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ use std::path::PathBuf;
77
use diesel::SqliteConnection;
88
use diesel::dsl::exists;
99
use diesel::prelude::*;
10-
use miden_node_db::{DatabaseError, Db};
10+
use miden_node_db::{DatabaseError, Db, SqlTypeConvert};
11+
use miden_protocol::block::{BlockHeader, BlockNumber};
1112
use miden_protocol::transaction::TransactionId;
12-
use miden_protocol::utils::Serializable;
13+
use miden_protocol::utils::{Deserializable, Serializable};
1314
use tracing::instrument;
1415

1516
use crate::COMPONENT;
1617
use crate::db::migrations::apply_migrations;
17-
use crate::db::models::ValidatedTransactionRowInsert;
18+
use crate::db::models::{BlockHeaderRowInsert, ValidatedTransactionRowInsert};
1819
use crate::tx_validation::ValidatedTransaction;
1920

2021
/// Open a connection to the DB and apply any pending migrations.
@@ -78,3 +79,59 @@ pub(crate) fn find_unvalidated_transactions(
7879
}
7980
Ok(unvalidated_tx_ids)
8081
}
82+
83+
/// Upserts a block header into the database.
84+
///
85+
/// Inserts a new row if no block header exists at the given block number, or replaces the
86+
/// existing block header if one already exists.
87+
#[instrument(target = COMPONENT, skip(conn, header), err)]
88+
pub fn upsert_block_header(
89+
conn: &mut SqliteConnection,
90+
header: &BlockHeader,
91+
) -> Result<(), DatabaseError> {
92+
let row = BlockHeaderRowInsert {
93+
block_num: header.block_num().to_raw_sql(),
94+
block_header: header.to_bytes(),
95+
};
96+
diesel::replace_into(schema::block_headers::table).values(row).execute(conn)?;
97+
Ok(())
98+
}
99+
100+
/// Loads the chain tip (block header with the highest block number) from the database.
101+
///
102+
/// Returns `None` if no block headers have been persisted (i.e. bootstrap has not been run).
103+
#[instrument(target = COMPONENT, skip(conn), err)]
104+
pub fn load_chain_tip(conn: &mut SqliteConnection) -> Result<Option<BlockHeader>, DatabaseError> {
105+
let row = schema::block_headers::table
106+
.order(schema::block_headers::block_num.desc())
107+
.select(schema::block_headers::block_header)
108+
.first::<Vec<u8>>(conn)
109+
.optional()?;
110+
111+
row.map(|bytes| {
112+
BlockHeader::read_from_bytes(&bytes)
113+
.map_err(|err| DatabaseError::deserialization("BlockHeader", err))
114+
})
115+
.transpose()
116+
}
117+
118+
/// Loads a block header by its block number.
119+
///
120+
/// Returns `None` if no block header exists at the given block number.
121+
#[instrument(target = COMPONENT, skip(conn), err)]
122+
pub fn load_block_header(
123+
conn: &mut SqliteConnection,
124+
block_num: BlockNumber,
125+
) -> Result<Option<BlockHeader>, DatabaseError> {
126+
let row = schema::block_headers::table
127+
.filter(schema::block_headers::block_num.eq(block_num.to_raw_sql()))
128+
.select(schema::block_headers::block_header)
129+
.first::<Vec<u8>>(conn)
130+
.optional()?;
131+
132+
row.map(|bytes| {
133+
BlockHeader::read_from_bytes(&bytes)
134+
.map_err(|err| DatabaseError::deserialization("BlockHeader", err))
135+
})
136+
.transpose()
137+
}

crates/validator/src/db/models.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ use miden_tx::utils::Serializable;
55
use crate::db::schema;
66
use crate::tx_validation::ValidatedTransaction;
77

8+
#[derive(Debug, Clone, Insertable)]
9+
#[diesel(table_name = schema::block_headers)]
10+
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
11+
pub struct BlockHeaderRowInsert {
12+
pub block_num: i64,
13+
pub block_header: Vec<u8>,
14+
}
15+
816
#[derive(Debug, Clone, PartialEq, Insertable)]
917
#[diesel(table_name = schema::validated_transactions)]
1018
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]

crates/validator/src/db/schema.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ diesel::table! {
1111
fee -> Binary,
1212
}
1313
}
14+
15+
diesel::table! {
16+
block_headers (block_num) {
17+
block_num -> BigInt,
18+
block_header -> Binary,
19+
}
20+
}

crates/validator/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
mod block_validation;
2-
mod db;
2+
pub mod db;
33
mod server;
44
mod signers;
55
mod tx_validation;

0 commit comments

Comments
 (0)