Skip to content

Feat/signer two phase commit #6082

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: develop
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE

### Added

- Added `SignerMessage::PreBlockCommit` for 2-phase commit block signing
- Added field `vm_error` to EventObserver transaction outputs
- Added new `ValidateRejectCode` values to the `/v3/block_proposal` endpoint
- Added `StateMachineUpdateContent::V1` to support a vector of `StacksTransaction` expected to be replayed in subsequent Stacks blocks
Expand Down
127 changes: 124 additions & 3 deletions libsigner/src/v0/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ MessageSlotID {
/// Block Response message from signers
BlockResponse = 1,
/// Signer State Machine Update
StateMachineUpdate = 2
StateMachineUpdate = 2,
/// Block Pre-commit message from signers before they commit to a block response
BlockPreCommit = 3
});

define_u8_enum!(
Expand Down Expand Up @@ -132,7 +134,9 @@ SignerMessageTypePrefix {
/// Mock block message from Epoch 2.5 miners
MockBlock = 5,
/// State machine update
StateMachineUpdate = 6
StateMachineUpdate = 6,
/// Block Pre-commit message
BlockPreCommit = 7
});

#[cfg_attr(test, mutants::skip)]
Expand All @@ -155,7 +159,7 @@ impl MessageSlotID {
#[cfg_attr(test, mutants::skip)]
impl Display for MessageSlotID {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}({})", self, self.to_u8())
write!(f, "{self:?}({})", self.to_u8())
}
}

Expand All @@ -179,6 +183,7 @@ impl From<&SignerMessage> for SignerMessageTypePrefix {
SignerMessage::MockSignature(_) => SignerMessageTypePrefix::MockSignature,
SignerMessage::MockBlock(_) => SignerMessageTypePrefix::MockBlock,
SignerMessage::StateMachineUpdate(_) => SignerMessageTypePrefix::StateMachineUpdate,
SignerMessage::BlockPreCommit(_) => SignerMessageTypePrefix::BlockPreCommit,
}
}
}
Expand All @@ -200,6 +205,8 @@ pub enum SignerMessage {
MockBlock(MockBlock),
/// A state machine update
StateMachineUpdate(StateMachineUpdate),
/// The pre commit message from signers for other signers to observe
BlockPreCommit(BlockPreCommit),
}

impl SignerMessage {
Expand All @@ -215,6 +222,7 @@ impl SignerMessage {
| Self::MockBlock(_) => None,
Self::BlockResponse(_) | Self::MockSignature(_) => Some(MessageSlotID::BlockResponse), // Mock signature uses the same slot as block response since its exclusively for epoch 2.5 testing
Self::StateMachineUpdate(_) => Some(MessageSlotID::StateMachineUpdate),
Self::BlockPreCommit(_) => Some(MessageSlotID::BlockPreCommit),
}
}
}
Expand All @@ -234,6 +242,9 @@ impl StacksMessageCodec for SignerMessage {
SignerMessage::StateMachineUpdate(state_machine_update) => {
state_machine_update.consensus_serialize(fd)
}
SignerMessage::BlockPreCommit(block_pre_commit) => {
block_pre_commit.consensus_serialize(fd)
}
}?;
Ok(())
}
Expand Down Expand Up @@ -271,6 +282,10 @@ impl StacksMessageCodec for SignerMessage {
let state_machine_update = StacksMessageCodec::consensus_deserialize(fd)?;
SignerMessage::StateMachineUpdate(state_machine_update)
}
SignerMessageTypePrefix::BlockPreCommit => {
let signer_signature_hash = StacksMessageCodec::consensus_deserialize(fd)?;
SignerMessage::BlockPreCommit(signer_signature_hash)
}
};
Ok(message)
}
Expand Down Expand Up @@ -792,6 +807,93 @@ impl StacksMessageCodec for StateMachineUpdate {
}
}

/// A block pre commit message
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockPreCommit {
/// The signer signature hash of the block being committed to
pub signer_signature_hash: Sha512Trunc256Sum,
/// The miner's signature across the BlockPreCommit message
pub signature: MessageSignature,
}

impl BlockPreCommit {
/// create a new unsigned block pre commit message
pub fn new_unsigned(signer_signature_hash: Sha512Trunc256Sum) -> Self {
Self {
signer_signature_hash,
signature: MessageSignature::empty(),
}
}

/// Create a new signed block pre commit message using the provided private key
pub fn new_signed(
signer_signature_hash: Sha512Trunc256Sum,
private_key: &StacksPrivateKey,
mainnet: bool,
) -> Result<Self, String> {
let mut pre_commit = Self::new_unsigned(signer_signature_hash);
pre_commit.sign(private_key, mainnet)?;
Ok(pre_commit)
}

/// Create a hash across the BlockPreCommit data. Note it cannot simply sign the signer_signature_hash directly
/// as this could be added prematurely to a NakamotoBlock
pub fn hash(&self, mainnet: bool) -> Sha256Sum {
let chain_id = if mainnet {
CHAIN_ID_MAINNET
} else {
CHAIN_ID_TESTNET
};
let domain_tuple = make_structured_data_domain("block-pre-commit", "1.0.0", chain_id);
let data = Value::buff_from(self.signer_signature_hash.as_bytes().into()).unwrap();
structured_data_message_hash(data, domain_tuple)
}

/// Sign the BlockPreCommit and set the internal signature field
pub fn sign(&mut self, private_key: &StacksPrivateKey, mainnet: bool) -> Result<(), String> {
let signature_hash = self.hash(mainnet);
self.signature = private_key.sign(signature_hash.as_bytes())?;
Ok(())
}

/// Verify the block pre commit against the provided public key
pub fn verify(&self, public_key: &StacksPublicKey, mainnet: bool) -> Result<bool, String> {
if self.signature == MessageSignature::empty() {
return Ok(false);
}
let signature_hash = self.hash(mainnet);
public_key
.verify(&signature_hash.0, &self.signature)
.map_err(|e| e.to_string())
}

/// Recover the public key from the BlockPreCommit
pub fn recover_public_key(&self, mainnet: bool) -> Result<StacksPublicKey, &'static str> {
if self.signature == MessageSignature::empty() {
return Err("No signature to recover public key from");
}
let signature_hash = self.hash(mainnet);
StacksPublicKey::recover_to_pubkey(signature_hash.as_bytes(), &self.signature)
}
}

impl StacksMessageCodec for BlockPreCommit {
fn consensus_serialize<W: Write>(&self, fd: &mut W) -> Result<(), CodecError> {
self.signer_signature_hash.consensus_serialize(fd)?;
self.signature.consensus_serialize(fd)?;
Ok(())
}

fn consensus_deserialize<R: Read>(fd: &mut R) -> Result<Self, CodecError> {
let signer_signature_hash = Sha512Trunc256Sum::consensus_deserialize(fd)?;
let signature = MessageSignature::consensus_deserialize(fd)?;
Ok(Self {
signer_signature_hash,
signature,
})
}
}

define_u8_enum!(
/// Enum representing the reject code type prefix
RejectCodeTypePrefix {
Expand Down Expand Up @@ -1747,6 +1849,12 @@ impl From<StateMachineUpdate> for SignerMessage {
}
}

impl From<BlockPreCommit> for SignerMessage {
fn from(block_pre_commit: BlockPreCommit) -> Self {
Self::BlockPreCommit(block_pre_commit)
}
}

#[cfg(test)]
mod test {
use blockstack_lib::chainstate::nakamoto::NakamotoBlockHeader;
Expand Down Expand Up @@ -2433,4 +2541,17 @@ mod test {

assert_eq!(signer_message, signer_message_deserialized);
}

#[test]
fn serde_block_pre_commit() {
let mut pre_commit = BlockPreCommit::new_unsigned(Sha512Trunc256Sum([0u8; 32]));
pre_commit
.sign(&StacksPrivateKey::random(), false)
.expect("Failed to sign pre-commit");
let serialized_pre_commit = pre_commit.serialize_to_vec();
let deserialized_pre_commit =
read_next::<BlockPreCommit, _>(&mut &serialized_pre_commit[..])
.expect("Failed to deserialize pre-commit");
assert_eq!(pre_commit, deserialized_pre_commit);
}
}
4 changes: 4 additions & 0 deletions stacks-signer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE

## [3.1.0.0.9.0]

### Added

- Added `SignerMessage::BlockPreCommit` message handling; signers now collect until a threshold is reached before issuing a block signature, implementing a proper 2-phase commit.

### Changed

- Upgraded `SUPPORTED_SIGNER_PROTOCOL_VERSION` to 1
Expand Down
8 changes: 8 additions & 0 deletions stacks-signer/src/monitoring/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ pub mod actions {
BLOCK_RESPONSES_SENT.with_label_values(&[label_value]).inc();
}

/// Increment the block pre-commit sent counter
pub fn increment_block_pre_commits_sent() {
BLOCK_PRE_COMMITS_SENT.inc();
}

/// Increment the number of block proposals received
pub fn increment_block_proposals_received() {
BLOCK_PROPOSALS_RECEIVED.inc();
Expand Down Expand Up @@ -203,6 +208,9 @@ pub mod actions {
/// Increment the block responses sent counter
pub fn increment_block_responses_sent(_accepted: bool) {}

/// Increment the block pre-commits sent counter
pub fn increment_block_pre_commits_sent() {}

/// Increment the number of block proposals received
pub fn increment_block_proposals_received() {}

Expand Down
5 changes: 5 additions & 0 deletions stacks-signer/src/monitoring/prometheus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ lazy_static! {
&["response_type"]
)
.unwrap();
pub static ref BLOCK_PRE_COMMITS_SENT: IntCounter = register_int_counter!(opts!(
"stacks_signer_block_pre_commits_sent",
"The number of block pre-commits sent by the signer"
))
.unwrap();
pub static ref BLOCK_PROPOSALS_RECEIVED: IntCounter = register_int_counter!(opts!(
"stacks_signer_block_proposals_received",
"The number of block proposals received by the signer"
Expand Down
101 changes: 100 additions & 1 deletion stacks-signer/src/signerdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,18 @@ CREATE TABLE stackerdb_tracking(
PRIMARY KEY (public_key, slot_id)
) STRICT;";

static CREATE_BLOCK_PRE_COMMITS_TABLE: &str = r#"
CREATE TABLE IF NOT EXISTS block_pre_commits (
-- The block sighash commits to all of the stacks and burnchain state as of its parent,
-- as well as the tenure itself so there's no need to include the reward cycle. Just
-- the sighash is sufficient to uniquely identify the block across all burnchain, PoX,
-- and stacks forks.
signer_signature_hash TEXT NOT NULL,
-- signer address committing to sign the block
signer_addr TEXT NOT NULL,
PRIMARY KEY (signer_signature_hash, signer_addr)
) STRICT;"#;

static SCHEMA_1: &[&str] = &[
DROP_SCHEMA_0,
CREATE_DB_CONFIG,
Expand Down Expand Up @@ -719,6 +731,11 @@ static SCHEMA_15: &[&str] = &[
"INSERT INTO db_config (version) VALUES (15);",
];

static SCHEMA_16: &[&str] = &[
CREATE_BLOCK_PRE_COMMITS_TABLE,
"INSERT INTO db_config (version) VALUES (16);",
];

struct Migration {
version: u32,
statements: &'static [&'static str],
Expand Down Expand Up @@ -785,11 +802,15 @@ static MIGRATIONS: &[Migration] = &[
version: 15,
statements: SCHEMA_15,
},
Migration {
version: 16,
statements: SCHEMA_16,
},
];

impl SignerDb {
/// The current schema version used in this build of the signer binary.
pub const SCHEMA_VERSION: u32 = 15;
pub const SCHEMA_VERSION: u32 = 16;

/// Create a new `SignerState` instance.
/// This will create a new SQLite database at the given path
Expand Down Expand Up @@ -1576,6 +1597,39 @@ impl SignerDb {
];
query_row(&self.db, query, args)
}

/// Record an observed block pre commit
pub fn add_block_pre_commit(
&self,
block_sighash: &Sha512Trunc256Sum,
address: &StacksAddress,
) -> Result<(), DBError> {
let qry = "INSERT OR REPLACE INTO block_pre_commits (signer_signature_hash, signer_addr) VALUES (?1, ?2);";
let args = params![block_sighash, address.to_string()];

debug!("Inserting block pre commit.";
"signer_signature_hash" => %block_sighash,
"signer_addr" => %address);

self.db.execute(qry, args)?;
Ok(())
}

/// Get all pre committers for a block
pub fn get_block_pre_committers(
&self,
block_sighash: &Sha512Trunc256Sum,
) -> Result<Vec<StacksAddress>, DBError> {
let qry = "SELECT signer_addr FROM block_pre_commits WHERE signer_signature_hash = ?1";
let args = params![block_sighash];
let addrs_txt: Vec<String> = query_rows(&self.db, qry, args)?;

let res: Result<Vec<_>, _> = addrs_txt
.into_iter()
.map(|addr| StacksAddress::from_string(&addr).ok_or(DBError::Corruption))
.collect();
res
}
}

fn try_deserialize<T>(s: Option<String>) -> Result<Option<T>, DBError>
Expand Down Expand Up @@ -2908,4 +2962,49 @@ pub mod tests {
assert_eq!(result.replay_tx_hash, format!("{replay_tx_hash}"));
assert!(!result.replay_tx_exhausted);
}

#[test]
fn insert_and_get_state_block_pre_commits() {
let db_path = tmp_db_path();
let db = SignerDb::new(db_path).expect("Failed to create signer db");
let block_sighash1 = Sha512Trunc256Sum([1u8; 32]);
let address1 = StacksAddress::p2pkh(
false,
&StacksPublicKey::from_private(&StacksPrivateKey::random()),
);
let block_sighash2 = Sha512Trunc256Sum([2u8; 32]);
let address2 = StacksAddress::p2pkh(
false,
&StacksPublicKey::from_private(&StacksPrivateKey::random()),
);
let address3 = StacksAddress::p2pkh(
false,
&StacksPublicKey::from_private(&StacksPrivateKey::random()),
);
assert!(db
.get_block_pre_committers(&block_sighash1)
.unwrap()
.is_empty());

db.add_block_pre_commit(&block_sighash1, &address1).unwrap();
assert_eq!(
db.get_block_pre_committers(&block_sighash1).unwrap(),
vec![address1]
);

db.add_block_pre_commit(&block_sighash1, &address2).unwrap();
let commits = db.get_block_pre_committers(&block_sighash1).unwrap();
assert_eq!(commits.len(), 2);
assert!(commits.contains(&address2));
assert!(commits.contains(&address1));

db.add_block_pre_commit(&block_sighash2, &address3).unwrap();
let commits = db.get_block_pre_committers(&block_sighash1).unwrap();
assert_eq!(commits.len(), 2);
assert!(commits.contains(&address2));
assert!(commits.contains(&address1));
let commits = db.get_block_pre_committers(&block_sighash2).unwrap();
assert_eq!(commits.len(), 1);
assert!(commits.contains(&address3));
}
}
Loading
Loading