Skip to content
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

feat: add Wallet Descriptor Support for Transaction Indexing #407

Merged
merged 6 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
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 @@ -126,6 +126,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 @@ -326,7 +331,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 @@ -422,6 +427,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
51 changes: 48 additions & 3 deletions components/chainhook-sdk/src/chainhooks/bitcoin/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,54 @@ use test_case::test_case;
true;
"OpReturn: EndsWith matches ASCII value"
)]
fn test_opreturn_evaluation(script_pubkey: &str, rule: MatchingRule, matches: bool) {
script_pubkey_evaluation(OutputPredicate::OpReturn(rule), script_pubkey, matches)
}

// Descriptor test cases have been taken from
// https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md#examples
// To generate the address run:
// `bdk-cli -n testnet wallet --descriptor <descriptor> get_new_address`
#[test_case(
"tb1q0ht9tyks4vh7p5p904t340cr9nvahy7um9zdem",
"wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)";
"Descriptor: P2WPKH"
)]
#[test_case(
"2NBtBzAJ84E3sTy1KooEHYVwmMhUVdJAyEa",
"sh(wpkh(03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556))";
"Descriptor: P2SH-P2WPKH"
)]
#[test_case(
"tb1qwu7hp9vckakyuw6htsy244qxtztrlyez4l7qlrpg68v6drgvj39qya5jch",
"wsh(multi(2,03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7,03774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cb,03d01115d548e7561b15c38f004d734633687cf4419620095bc5b0f47070afe85a))";
"Descriptor: P2WSH 2-of-3 multisig output"
)]
fn test_descriptor_evaluation(addr: &str, expr: &str) {
// turn the address into a script_pubkey with a 0x prefix, as expected by the evaluator.
let script_pubkey = Address::from_str(addr).unwrap().script_pubkey();
let matching_script_pubkey = format!("0x{}", hex::encode(script_pubkey));

let rule = DescriptorMatchingRule {
expression: expr.to_string(),
// TODO: test ranges
range: None,
};

// matching against the script_pubkey generated from the address should match.
script_pubkey_evaluation(
OutputPredicate::Descriptor(rule.clone()),
&matching_script_pubkey,
true,
);

// matching against a fake script_pubkey should not match.
script_pubkey_evaluation(OutputPredicate::Descriptor(rule.clone()), "0xffff", false);
}

fn test_script_pubkey_evaluation(script_pubkey: &str, rule: MatchingRule, matches: bool) {
let predicate = BitcoinPredicateType::Outputs(OutputPredicate::OpReturn(rule));
// script_pubkey_evaluation is a helper that evaluates a a script_pubkey against a transaction predicate.
fn script_pubkey_evaluation(output: OutputPredicate, script_pubkey: &str, matches: bool) {
let predicate = BitcoinPredicateType::Outputs(output);

let outputs = vec![TxOut {
value: 0,
Expand Down Expand Up @@ -83,5 +128,5 @@ fn test_script_pubkey_evaluation(script_pubkey: &str, rule: MatchingRule, matche
tracer: false,
};

assert_eq!(matches, predicate.evaluate_transaction_predicate(&tx, &ctx),);
assert_eq!(matches, predicate.evaluate_transaction_predicate(&tx, &ctx));
}
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 @@ -511,6 +511,7 @@ pub enum OutputPredicate {
P2sh(ExactMatchingRule),
P2wpkh(ExactMatchingRule),
P2wsh(ExactMatchingRule),
Descriptor(DescriptorMatchingRule),
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
Expand Down Expand Up @@ -661,6 +662,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
25 changes: 25 additions & 0 deletions docs/how-to-guides/how-to-use-chainhooks-with-bitcoin.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,31 @@ Get any transaction, including a `p2wsh` output paying a given recipient:

`p2wsh` (Pay-to-Witness-Script-Hash) is a Bitcoin transaction output script type used in Segregated Witness (SegWit) that enables users to send funds to a hashed script, allowing for more complex transaction conditions and greater scalability by separating the script from the transaction data.

**Wallet Descriptors** provide a compact and semi-standardized method for describing how scripts and addresses within a wallet are generated. 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
```
The following predicate should be defined:
```json
{
"if_this": {
"scope": "outputs",
"descriptor": {
"expression": "wpkh(tprv8ZgxMBicQKsPePxn6j3TjvB2MBzQkuhGgc6oRh2WZancZQgxktcnjZJ44XdsRiw3jNkbVTK9JW6KFHvnRKgAMtSyuBevMJprSkZ4PTfmTgV/84'/1'/0'/0/*)",
"range": [0,3]
}
}
}
```

Get any Bitcoin transaction, including a Block commitment. Broadcasted payloads include _Proof of Transfer_ reward information:

```json
Expand Down
Loading