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
107 changes: 97 additions & 10 deletions components/chainhook-sdk/src/chainhooks/stacks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ use crate::utils::{AbstractStacksBlock, Context, MAX_BLOCK_HEIGHTS_ENTRIES};

use super::types::validate_txid;
use super::types::{
append_error_context, BlockIdentifierIndexRule, ChainhookInstance, ExactMatchingRule,
append_error_context, is_hex, BlockIdentifierIndexRule, ChainhookInstance, ExactMatchingRule,
HookAction,
};
use chainhook_types::{
BlockIdentifier, StacksChainEvent, StacksNetwork, StacksNonConsensusEventData,
StacksTransactionData, StacksTransactionEvent, StacksTransactionEventPayload,
StacksTransactionKind, TransactionIdentifier,
StacksNonConsensusEventPayloadData, StacksTransactionData, StacksTransactionEvent,
StacksTransactionEventPayload, StacksTransactionKind, TransactionIdentifier,
};
use clarity::codec::StacksMessageCodec;
use clarity::vm::types::{
Expand Down Expand Up @@ -310,11 +310,14 @@ impl StacksPredicate {
}
}
#[cfg(feature = "stacks-signers")]
StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(_)) => {
// TODO(rafaelcr): Validate pubkey format
StacksPredicate::SignerMessage(predicate) => {
if let Err(e) = predicate.validate() {
return Err(append_error_context(
"invalid predicate for scope 'signer_message'",
vec![e],
));
}
}
#[cfg(feature = "stacks-signers")]
StacksPredicate::SignerMessage(StacksSignerMessagePredicate::AfterTimestamp(_)) => {}
}
Ok(())
}
Expand All @@ -328,7 +331,73 @@ pub enum StacksSignerMessagePredicate {
}

impl StacksSignerMessagePredicate {
// TODO(rafaelcr): Write validators
pub fn validate(&self) -> Result<(), String> {
match self {
StacksSignerMessagePredicate::AfterTimestamp(timestamp) => {
validate_timestamp(*timestamp)
}
StacksSignerMessagePredicate::FromSignerPubKey(pubkey) => {
validate_signer_pubkey(pubkey)
}
}
}
}

fn validate_timestamp(timestamp: u64) -> Result<(), String> {
if timestamp == 0 {
return Err("timestamp must be greater than 0".into());
}
// Check for unreasonably far future timestamps (year 2100)
const YEAR_2100_TIMESTAMP: u64 = 4102444800000; // milliseconds
if timestamp > YEAR_2100_TIMESTAMP {
return Err("timestamp must be a reasonable Unix timestamp in milliseconds (before year 2100)".into());
}
Ok(())
}

fn validate_signer_pubkey(pubkey: &String) -> Result<(), String> {
// Remove 0x prefix if present
let pubkey_hex = if pubkey.starts_with("0x") || pubkey.starts_with("0X") {
&pubkey[2..]
} else {
pubkey.as_str()
};

// Check if it's valid hex
if !is_hex(pubkey_hex) {
return Err("signer public key must be a hexadecimal string".into());
}

// secp256k1 public keys can be:
// - Compressed: 33 bytes (66 hex characters)
// - Uncompressed: 65 bytes (130 hex characters)
let len = pubkey_hex.len();

// Validate compressed key (66 hex characters)
if len == 66 {
let prefix = &pubkey_hex[0..2];
if prefix != "02" && prefix != "03" {
return Err(
"compressed signer public key must start with '02' or '03'".into(),
);
}
return Ok(());
}

// Validate uncompressed key (130 hex characters)
if len == 130 {
let prefix = &pubkey_hex[0..2];
if prefix != "04" {
return Err("uncompressed signer public key must start with '04'".into());
}
return Ok(());
}

// If we reach here, the length is invalid
Err(
"signer public key must be a valid secp256k1 public key (33 bytes compressed or 65 bytes uncompressed), represented as a hexadecimal string"
.into(),
)
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
Expand Down Expand Up @@ -863,8 +932,26 @@ pub fn evaluate_stacks_predicate_on_non_consensus_events<'a>(
occurrences.push(event);
}
}
StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(_)) => {
// TODO(rafaelcr): Evaluate on pubkey
StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(
expected_pubkey,
)) => {
let StacksNonConsensusEventPayloadData::SignerMessage(chunk) = &event.payload;
// Normalize both pubkeys by removing "0x" prefix if present for comparison
let normalized_expected = if expected_pubkey.starts_with("0x") || expected_pubkey.starts_with("0X") {
&expected_pubkey[2..]
} else {
expected_pubkey.as_str()
};

let normalized_actual = if chunk.pubkey.starts_with("0x") || chunk.pubkey.starts_with("0X") {
&chunk.pubkey[2..]
} else {
chunk.pubkey.as_str()
};

if normalized_expected.eq_ignore_ascii_case(normalized_actual) {
occurrences.push(event);
}
}
StacksPredicate::BlockHeight(_)
| StacksPredicate::ContractDeployment(_)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use std::collections::BTreeMap;
use crate::chainhooks::stacks::{StacksChainhookSpecification, StacksChainhookSpecificationNetworkMap, StacksContractCallBasedPredicate, StacksContractDeploymentPredicate, StacksPredicate, StacksPrintEventBasedPredicate};
#[cfg(feature = "stacks-signers")]
use crate::chainhooks::stacks::StacksSignerMessagePredicate;
use crate::chainhooks::types::*;
use crate::chainhooks::types::HttpHook;
use chainhook_types::StacksNetwork;
Expand Down Expand Up @@ -33,6 +35,25 @@ lazy_static! {
static ref PRINT_EVENT_ID_ERR: String = "invalid predicate for scope 'print_event': invalid contract identifier: ParseError(\"Invalid principal literal: base58ck checksum 0x147e6835 does not match expected 0x9b3dfe6a\")".into();
static ref INVALID_REGEX_ERR: String = "invalid predicate for scope 'print_event': invalid regex: regex parse error:\n [\\]\n ^\nerror: unclosed character class".into();

// Signer message predicates (secp256k1 pubkeys: compressed=66 hex chars, uncompressed=130 hex chars)
static ref COMPRESSED_PUBKEY_VALID_WITH_PREFIX: String = "0x02a1b2c3d4e5f67890123456789012345678901234567890123456789012345678".into();
static ref COMPRESSED_PUBKEY_VALID_NO_PREFIX: String = "02a1b2c3d4e5f67890123456789012345678901234567890123456789012345678".into();
static ref UNCOMPRESSED_PUBKEY_VALID: String = "0x04a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890ab123456789012345678901234567890123456789012345678901234567890".into();
static ref PUBKEY_INVALID_LENGTH: String = "0x02a1b2c3d4".into();
static ref PUBKEY_INVALID_HEX: String = "0x02g1b2c3d4e5f67890123456789012345678901234567890123456789012345678".into();
static ref PUBKEY_INVALID_COMPRESSED_PREFIX: String = "0x01a1b2c3d4e5f67890123456789012345678901234567890123456789012345678".into();
static ref PUBKEY_INVALID_UNCOMPRESSED_PREFIX: String = "0x05a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890ab123456789012345678901234567890123456789012345678901234567890".into();
static ref TIMESTAMP_VALID: u64 = 1704067200000; // Jan 1, 2024 in milliseconds
static ref TIMESTAMP_ZERO: u64 = 0;
static ref TIMESTAMP_TOO_FAR_FUTURE: u64 = 5000000000000; // Beyond year 2100

static ref SIGNER_PUBKEY_LENGTH_ERR: String = "invalid predicate for scope 'signer_message': signer public key must be a valid secp256k1 public key (33 bytes compressed or 65 bytes uncompressed), represented as a hexadecimal string".into();
static ref SIGNER_PUBKEY_HEX_ERR: String = "invalid predicate for scope 'signer_message': signer public key must be a hexadecimal string".into();
static ref SIGNER_PUBKEY_COMPRESSED_PREFIX_ERR: String = "invalid predicate for scope 'signer_message': compressed signer public key must start with '02' or '03'".into();
static ref SIGNER_PUBKEY_UNCOMPRESSED_PREFIX_ERR: String = "invalid predicate for scope 'signer_message': uncompressed signer public key must start with '04'".into();
static ref SIGNER_TIMESTAMP_ZERO_ERR: String = "invalid predicate for scope 'signer_message': timestamp must be greater than 0".into();
static ref SIGNER_TIMESTAMP_FAR_FUTURE_ERR: String = "invalid predicate for scope 'signer_message': timestamp must be a reasonable Unix timestamp in milliseconds (before year 2100)".into();

static ref INVALID_PREDICATE: StacksPredicate = StacksPredicate::PrintEvent(StacksPrintEventBasedPredicate::MatchesRegex { contract_identifier: CONTRACT_ID_INVALID_ADDRESS.clone(), regex: INVALID_REGEX.clone() });
static ref INVALID_HOOK_ACTION: HookAction =
HookAction::HttpPost(HttpHook { url: "".into(), authorization_header: "\n".into() });
Expand Down Expand Up @@ -177,6 +198,58 @@ lazy_static! {
&StacksPredicate::Txid(ExactMatchingRule::Equals(TXID_VALID.clone())),
None; "txid just right"
)]
// StacksPredicate::SignerMessage - Pubkey validation
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(COMPRESSED_PUBKEY_VALID_WITH_PREFIX.clone())),
None; "signer pubkey compressed with prefix"
)]
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(COMPRESSED_PUBKEY_VALID_NO_PREFIX.clone())),
None; "signer pubkey compressed no prefix"
)]
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(UNCOMPRESSED_PUBKEY_VALID.clone())),
None; "signer pubkey uncompressed valid"
)]
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(PUBKEY_INVALID_LENGTH.clone())),
Some(vec![SIGNER_PUBKEY_LENGTH_ERR.clone()]); "signer pubkey invalid length"
)]
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(PUBKEY_INVALID_HEX.clone())),
Some(vec![SIGNER_PUBKEY_HEX_ERR.clone()]); "signer pubkey invalid hex"
)]
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(PUBKEY_INVALID_COMPRESSED_PREFIX.clone())),
Some(vec![SIGNER_PUBKEY_COMPRESSED_PREFIX_ERR.clone()]); "signer pubkey invalid compressed prefix"
)]
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(PUBKEY_INVALID_UNCOMPRESSED_PREFIX.clone())),
Some(vec![SIGNER_PUBKEY_UNCOMPRESSED_PREFIX_ERR.clone()]); "signer pubkey invalid uncompressed prefix"
)]
// StacksPredicate::SignerMessage - Timestamp validation
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::AfterTimestamp(*TIMESTAMP_VALID)),
None; "signer timestamp valid"
)]
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::AfterTimestamp(*TIMESTAMP_ZERO)),
Some(vec![SIGNER_TIMESTAMP_ZERO_ERR.clone()]); "signer timestamp zero"
)]
#[cfg(feature = "stacks-signers")]
#[test_case(
&StacksPredicate::SignerMessage(StacksSignerMessagePredicate::AfterTimestamp(*TIMESTAMP_TOO_FAR_FUTURE)),
Some(vec![SIGNER_TIMESTAMP_FAR_FUTURE_ERR.clone()]); "signer timestamp too far future"
)]
fn it_validates_stacks_predicates(predicate: &StacksPredicate, expected_err: Option<Vec<String>>) {
if let Err(e) = predicate.validate() {
if let Some(expected) = expected_err {
Expand Down