Skip to content

Commit

Permalink
feat: predicate validation (#611)
Browse files Browse the repository at this point in the history
### Description

This PR adds validation for predicates. The
`ChainhookSpecificationNetworkMap::validate(&self)` method will now
check each of the fields in a predicate that could have invalid data and
return a string with _all_ of the errors separated by a `"\n"`.

I'm open to other ways of formatting the returned errors, but I think it
will be nice for users to see _everything_ that is wrong with their spec
in the first use rather than being given just the first error.



### Example

Here is an example result:
```
invalid Stacks predicate 'predicate_name' for network simnet: invalid 'then_that' value: invalid 'http_post' data: url string must be a valid Url: relative URL without a base
invalid Stacks predicate 'predicate_name' for network simnet: invalid 'then_that' value: invalid 'http_post' data: auth header must be a valid header value: failed to parse header value
invalid Stacks predicate 'predicate_name' for network simnet: invalid 'if_this' value: invalid predicate for scope 'print_event': invalid contract identifier: ParseError("Invalid principal literal: base58ck checksum 0x147e6835 does not match expected 0x9b3dfe6a")
```

---

### Checklist

- [x] All tests pass
- [x] Tests added in this PR (if applicable)
  • Loading branch information
MicaiahReid authored Jul 5, 2024
1 parent 1779def commit 67e28b1
Show file tree
Hide file tree
Showing 11 changed files with 958 additions and 62 deletions.
2 changes: 2 additions & 0 deletions components/chainhook-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ async fn handle_command(opts: Opts, ctx: Context) -> Result<(), String> {
let mut config =
Config::default(false, cmd.testnet, cmd.mainnet, &cmd.config_path)?;
let predicate = load_predicate_from_path(&cmd.predicate_path)?;
predicate.validate()?;
match predicate {
ChainhookSpecificationNetworkMap::Bitcoin(predicate) => {
let predicate_spec = match predicate
Expand Down Expand Up @@ -578,6 +579,7 @@ async fn handle_command(opts: Opts, ctx: Context) -> Result<(), String> {
let config = Config::default(false, cmd.testnet, cmd.mainnet, &cmd.config_path)?;
let predicate: ChainhookSpecificationNetworkMap =
load_predicate_from_path(&cmd.predicate_path)?;
predicate.validate()?;

match predicate {
ChainhookSpecificationNetworkMap::Bitcoin(predicate) => {
Expand Down
2 changes: 1 addition & 1 deletion components/chainhook-cli/src/service/http_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::{
};

use chainhook_sdk::{
chainhooks::types::{ChainhookSpecificationNetworkMap, ChainhookInstance},
chainhooks::types::{ChainhookInstance, ChainhookSpecificationNetworkMap},
observer::ObserverCommand,
utils::Context,
};
Expand Down
2 changes: 1 addition & 1 deletion components/chainhook-cli/src/service/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ async fn it_handles_bitcoin_predicates_with_network(network: &str) {
#[test_case(json!({ "scope": "outputs","p2sh": {"equals": "2MxDJ723HBJtEMa2a9vcsns4qztxBuC8Zb2"}}) ; "with scope outputs type p2sh")]
#[test_case(json!({"scope": "outputs","p2wpkh": {"equals": "bcrt1qnxknq3wqtphv7sfwy07m7e4sr6ut9yt6ed99jg"}}) ; "with scope outputs type p2wpkh")]
#[test_case(json!({"scope": "outputs","p2wsh": {"equals": "bc1qklpmx03a8qkv263gy8te36w0z9yafxplc5kwzc"}}) ; "with scope outputs type p2wsh")]
#[test_case(json!({"scope": "outputs","descriptor": {"expression": "a descriptor", "range": [0,3]}}) ; "with scope outputs type descriptor")]
#[test_case(json!({"scope": "outputs","descriptor": {"expression": "wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)", "range": [0,3]}}) ; "with scope outputs type descriptor")]
#[test_case(json!({"scope": "stacks_protocol","operation": "stacker_rewarded"}) ; "with scope stacks_protocol operation stacker_rewarded")]
#[test_case(json!({"scope": "stacks_protocol","operation": "block_committed"}) ; "with scope stacks_protocol operation block_committed")]
#[test_case(json!({"scope": "stacks_protocol","operation": "leader_registered"}) ; "with scope stacks_protocol operation leader_registered")]
Expand Down
223 changes: 197 additions & 26 deletions components/chainhook-sdk/src/chainhooks/bitcoin/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use super::types::{ChainhookInstance, ExactMatchingRule, HookAction, MatchingRule};
use crate::utils::Context;
use super::types::{
append_error_context, validate_txid, ChainhookInstance, ExactMatchingRule, HookAction,
MatchingRule,
};
use crate::utils::{Context, MAX_BLOCK_HEIGHTS_ENTRIES};

use bitcoincore_rpc_json::bitcoin::{address::Payload, Address};
use chainhook_types::{
Expand Down Expand Up @@ -49,6 +52,90 @@ pub struct BitcoinChainhookSpecification {
pub action: HookAction,
}

impl BitcoinChainhookSpecification {
pub fn new(predicate: BitcoinPredicateType, action: HookAction) -> Self {
BitcoinChainhookSpecification {
blocks: None,
start_block: None,
end_block: None,
expire_after_occurrence: None,
include_proof: None,
include_inputs: None,
include_outputs: None,
include_witness: None,
predicate,
action,
}
}

pub fn blocks(&mut self, blocks: Vec<u64>) -> &mut Self {
self.blocks = Some(blocks);
self
}

pub fn start_block(&mut self, start_block: u64) -> &mut Self {
self.start_block = Some(start_block);
self
}

pub fn end_block(&mut self, end_block: u64) -> &mut Self {
self.end_block = Some(end_block);
self
}

pub fn expire_after_occurrence(&mut self, occurrence: u64) -> &mut Self {
self.expire_after_occurrence = Some(occurrence);
self
}

pub fn include_proof(&mut self, do_include: bool) -> &mut Self {
self.include_proof = Some(do_include);
self
}

pub fn include_inputs(&mut self, do_include: bool) -> &mut Self {
self.include_inputs = Some(do_include);
self
}

pub fn include_outputs(&mut self, do_include: bool) -> &mut Self {
self.include_outputs = Some(do_include);
self
}

pub fn include_witness(&mut self, do_include: bool) -> &mut Self {
self.include_witness = Some(do_include);
self
}

pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = vec![];
if let Err(e) = self.action.validate() {
errors.append(&mut append_error_context("invalid 'then_that' value", e));
}
if let Err(e) = self.predicate.validate() {
errors.append(&mut append_error_context("invalid 'if_this' value", e));
}

if let Some(end_block) = self.end_block {
let start_block = self.start_block.unwrap_or(0);
if start_block > end_block {
errors.push(
"Chainhook specification field `end_block` should be greater than `start_block`.".into()
);
}
if (end_block - start_block) > MAX_BLOCK_HEIGHTS_ENTRIES {
errors.push(format!("Chainhook specification exceeds max number of blocks to scan. Maximum: {}, Attempted: {}", MAX_BLOCK_HEIGHTS_ENTRIES, (end_block - start_block)));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}

/// Maps some [BitcoinChainhookSpecification] to a corresponding [BitcoinNetwork]. This allows maintaining one
/// serialized predicate file for a given predicate on each network.
///
Expand Down Expand Up @@ -184,6 +271,41 @@ pub enum BitcoinPredicateType {
OrdinalsProtocol(OrdinalOperations),
}

impl BitcoinPredicateType {
pub fn validate(&self) -> Result<(), Vec<String>> {
match self {
BitcoinPredicateType::Block => {}
BitcoinPredicateType::Txid(ExactMatchingRule::Equals(txid)) => {
if let Err(e) = validate_txid(txid) {
return Err(append_error_context(
"invalid predicate for scope 'txid'",
vec![e],
));
}
}
BitcoinPredicateType::Inputs(input) => {
if let Err(e) = input.validate() {
return Err(append_error_context(
"invalid predicate for scope 'inputs'",
e,
));
}
}
BitcoinPredicateType::Outputs(outputs) => {
if let Err(e) = outputs.validate() {
return Err(append_error_context(
"invalid predicate for scope 'outputs'",
vec![e],
));
}
}
BitcoinPredicateType::StacksProtocol(_) => {}
BitcoinPredicateType::OrdinalsProtocol(_) => {}
}
Ok(())
}
}

pub struct BitcoinTriggerChainhook<'a> {
pub chainhook: &'a BitcoinChainhookInstance,
pub apply: Vec<(Vec<&'a BitcoinTransactionData>, &'a BitcoinBlockData)>,
Expand All @@ -208,6 +330,15 @@ pub enum InputPredicate {
WitnessScript(MatchingRule),
}

impl InputPredicate {
pub fn validate(&self) -> Result<(), Vec<String>> {
match self {
InputPredicate::Txid(txin) => txin.validate(),
InputPredicate::WitnessScript(_) => Ok(()),
}
}
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OutputPredicate {
Expand All @@ -219,6 +350,20 @@ pub enum OutputPredicate {
Descriptor(DescriptorMatchingRule),
}

impl OutputPredicate {
pub fn validate(&self) -> Result<(), String> {
match self {
OutputPredicate::OpReturn(_) => {}
OutputPredicate::P2pkh(ExactMatchingRule::Equals(_p2pkh)) => {}
OutputPredicate::P2sh(ExactMatchingRule::Equals(_p2sh)) => {}
OutputPredicate::P2wpkh(ExactMatchingRule::Equals(_p2wpkh)) => {}
OutputPredicate::P2wsh(ExactMatchingRule::Equals(_p2wsh)) => {}
OutputPredicate::Descriptor(descriptor) => descriptor.validate()?,
}
Ok(())
}
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case", tag = "operation")]
pub enum StacksOperations {
Expand Down Expand Up @@ -348,6 +493,15 @@ pub struct TxinPredicate {
pub vout: u32,
}

impl TxinPredicate {
pub fn validate(&self) -> Result<(), Vec<String>> {
if let Err(e) = validate_txid(&self.txid) {
return Err(vec![e]);
}
Ok(())
}
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct DescriptorMatchingRule {
Expand All @@ -357,6 +511,43 @@ pub struct DescriptorMatchingRule {
pub range: Option<[u32; 2]>,
}

impl DescriptorMatchingRule {
pub fn validate(&self) -> Result<(), String> {
let _ = self.derive_script_pubkeys()?;
Ok(())
}

pub fn derive_script_pubkeys(&self) -> Result<Vec<String>, String> {
let DescriptorMatchingRule { expression, range } = self;
// To derive from descriptors, we need to provide a secp context.
let (sig, ver) = (&Secp256k1::signing_only(), &Secp256k1::verification_only());
let (desc, _) = Descriptor::parse_descriptor(&sig, expression)
.map_err(|e| format!("invalid descriptor: {}", e.to_string()))?;

// If the descriptor is derivable (`has_wildcard()`), we rely on the `range` field
// defined by the predicate OR fallback to a default range of [0,5] when not set.
// When the descriptor is not derivable we force to create a unique iteration by
// ranging over [0,1].
let range = if desc.has_wildcard() {
range.unwrap_or([0, 5])
} else {
[0, 1]
};

let mut script_pubkeys = vec![];
// Derive the addresses and try to match them against the outputs.
for i in range[0]..range[1] {
let derived = desc
.derived_descriptor(&ver, i)
.map_err(|e| format!("error deriving descriptor: {}", e))?;

// Extract and encode the derived pubkey.
script_pubkeys.push(hex::encode(derived.script_pubkey().as_bytes()));
}
Ok(script_pubkeys)
}
}

// deserialize_descriptor_range makes sure that the range value is valid.
fn deserialize_descriptor_range<'de, D>(deserializer: D) -> Result<Option<[u32; 2]>, D::Error>
where
Expand Down Expand Up @@ -788,31 +979,11 @@ impl BitcoinPredicateType {
}
false
}
BitcoinPredicateType::Outputs(OutputPredicate::Descriptor(
DescriptorMatchingRule { expression, range },
)) => {
// To derive from descriptors, we need to provide a secp context.
let (sig, ver) = (&Secp256k1::signing_only(), &Secp256k1::verification_only());
let (desc, _) = Descriptor::parse_descriptor(&sig, expression).unwrap();

// If the descriptor is derivable (`has_wildcard()`), we rely on the `range` field
// defined by the predicate OR fallback to a default range of [0,5] when not set.
// When the descriptor is not derivable we force to create a unique iteration by
// ranging over [0,1].
let range = if desc.has_wildcard() {
range.unwrap_or([0, 5])
} else {
[0, 1]
};

// Derive the addresses and try to match them against the outputs.
for i in range[0]..range[1] {
let derived = desc.derived_descriptor(&ver, i).unwrap();

// Extract and encode the derived pubkey.
let script_pubkey = hex::encode(derived.script_pubkey().as_bytes());
BitcoinPredicateType::Outputs(OutputPredicate::Descriptor(descriptor)) => {
let script_pubkeys = descriptor.derive_script_pubkeys().unwrap();

// Match that script against the tx outputs.
for script_pubkey in script_pubkeys {
// Match the script against the tx outputs.
for (index, output) in tx.metadata.outputs.iter().enumerate() {
if output.script_pubkey[2..] == script_pubkey {
ctx.try_log(|logger| {
Expand Down
Loading

0 comments on commit 67e28b1

Please sign in to comment.