Skip to content

Commit

Permalink
feat: add Wallet Descriptor Support for Transaction Indexing
Browse files Browse the repository at this point in the history
This introduces support for wallet descriptors.
Descriptors provide a compact and semi-standardized method for
describing how scripts and addresses within a wallet are generated[1].

Chainhooks users that want to track addresses derived from an extended
pubkey or a multisig-wallet for example, can now rely on this feature
instead of defining one predicate per address.

For example if we wanted to track the first 3 addressed generated by
the following descriptor:

```
wpkh(tprv8ZgxMBicQKsPePxn6j3TjvB2MBzQkuhGgc6oRh2WZancZQgxktcnjZJ44XdsRiw3jNkbVTK9JW6KFHvnRKgAMtSyuBevMJprSkZ4PTfmTgV/84'/1'/0'/0/*)
```

which reads: describe a P2WPKH output with the specified extended public
key, and produces these BIP84 addresses:

```
bcrt1qzy2rdyvu8c57qd8exyyp0mw7dk5drsu9ewzdsu
bcrt1qsfsjnagr29m8h3a3vdand2s85cg4cefkcwk2fy
bcrt1qauewfytqe5mtr0xwp786r6fl39kmum2lr65kmj
```

we could use the following predicate:

```json
...
  "networks": {
    "regtest": {
      "if_this": {
        "scope": "outputs",
        "descriptor": {
          "expression": "wpkh(tprv8ZgxMBicQKsPePxn6j3TjvB2MBzQkuhGgc6oRh2WZancZQgxktcnjZJ44XdsRiw3jNkbVTK9JW6KFHvnRKgAMtSyuBevMJprSkZ4PTfmTgV/84'/1'/0'/0/*)",
          "range": [0,3]
        }
      },
      "then_that": {
        "file_append": {
          "path": "txns.txt"
        }
      }
    }
  }
...

```

1: https://bitcoindevkit.org/descriptors/
  • Loading branch information
qustavo committed Sep 24, 2023
1 parent 5503c3d commit edf4da0
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 9 deletions.
74 changes: 69 additions & 5 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions components/chainhook-cli/src/service/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,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": "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
1 change: 1 addition & 0 deletions components/chainhook-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dashmap = "5.4.0"
fxhash = "0.2.1"
lazy_static = "1.4.0"
regex = "1.9.3"
miniscript = "10.0.0"

[dev-dependencies]
test-case = "3.1.0"
Expand Down
55 changes: 52 additions & 3 deletions components/chainhook-sdk/src/chainhooks/bitcoin/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::types::{
BitcoinChainhookSpecification, BitcoinPredicateType, ExactMatchingRule, HookAction,
InputPredicate, MatchingRule, OrdinalOperations, OutputPredicate, StacksOperations,
BitcoinChainhookSpecification, BitcoinPredicateType, DescriptorMatchingRule, ExactMatchingRule,
HookAction, InputPredicate, MatchingRule, OrdinalOperations, OutputPredicate, StacksOperations,
};
use crate::utils::Context;

Expand All @@ -11,6 +11,11 @@ use chainhook_types::{
StacksBaseChainOperation, TransactionIdentifier,
};

use hiro_system_kit::slog;

use miniscript::bitcoin::secp256k1::Secp256k1;
use miniscript::Descriptor;

use reqwest::{Client, Method};
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, HashMap};
Expand Down Expand Up @@ -305,7 +310,7 @@ impl BitcoinPredicateType {
pub fn evaluate_transaction_predicate(
&self,
tx: &BitcoinTransactionData,
_ctx: &Context,
ctx: &Context,
) -> bool {
// TODO(lgalabru): follow-up on this implementation
match &self {
Expand Down Expand Up @@ -385,6 +390,50 @@ 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());

// Match that 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| {
slog::debug!(
logger,
"Descriptor: Matched pubkey {:?} on tx {:?} output {}",
script_pubkey,
tx.transaction_identifier.get_hash_bytes_str(),
index,
)
});

return true;
}
}
}

false
}
BitcoinPredicateType::Inputs(InputPredicate::Txid(predicate)) => {
// TODO(lgalabru): add support for transaction chainhing, if enabled
for input in tx.metadata.inputs.iter() {
Expand Down
27 changes: 26 additions & 1 deletion components/chainhook-sdk/src/chainhooks/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use chainhook_types::{BitcoinNetwork, StacksNetwork};
use reqwest::Url;
use serde::ser::{SerializeSeq, Serializer};
use serde::{Deserialize, Serialize};
use serde::{de, Deserialize, Deserializer, Serialize};

use schemars::JsonSchema;

Expand Down Expand Up @@ -558,6 +558,7 @@ pub enum OutputPredicate {
P2sh(ExactMatchingRule),
P2wpkh(ExactMatchingRule),
P2wsh(ExactMatchingRule),
Descriptor(DescriptorMatchingRule),
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
Expand Down Expand Up @@ -708,6 +709,30 @@ pub enum ExactMatchingRule {
Equals(String),
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct DescriptorMatchingRule {
// expression defines the bitcoin descriptor.
pub expression: String,
#[serde(default, deserialize_with = "deserialize_descriptor_range")]
pub range: Option<[u32; 2]>,
}

// 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
D: Deserializer<'de>,
{
let range: [u32; 2] = Deserialize::deserialize(deserializer)?;
if !(range[0] < range[1]) {
Err(de::Error::custom(
"First element of 'range' must be lower than the second element",
))
} else {
Ok(Some(range))
}
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BlockIdentifierHashRule {
Expand Down

0 comments on commit edf4da0

Please sign in to comment.