From 768c8df1d1a60f33eae02abc07e121be579a8bc1 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:28:02 +0100 Subject: [PATCH 1/2] feat(derive): add P2TR (Taproot) address support Derive, match, and output bc1p Taproot addresses alongside the existing P2PKH and P2WPKH formats. Covers the full pipeline: KeyDeriver, Matcher, all output handlers, and the Parquet/Iceberg storage schemas (19 -> 20 columns). Closes #67 --- src/derive.rs | 13 +++++++++++-- src/matcher.rs | 26 ++++++++++++++++++++++++++ src/output/console.rs | 3 +++ src/output/multi.rs | 1 + src/output/storage.rs | 5 +++++ src/storage/iceberg/schema.rs | 8 +++++++- src/storage/parquet_backend.rs | 4 ++-- src/storage/schema.rs | 29 ++++++++++++++++++++--------- 8 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/derive.rs b/src/derive.rs index 3dd6fa6..a3fbb93 100644 --- a/src/derive.rs +++ b/src/derive.rs @@ -38,15 +38,18 @@ pub struct DerivedKey { pub p2pkh_uncompressed: String, /// P2WPKH (bech32) address pub p2wpkh: String, + /// P2TR (Taproot bech32m) address + pub p2tr: String, } impl DerivedKey { /// Get all addresses as slice for matching. - pub fn addresses(&self) -> [&str; 3] { + pub fn addresses(&self) -> [&str; 4] { [ &self.p2pkh_compressed, &self.p2pkh_uncompressed, &self.p2wpkh, + &self.p2tr, ] } } @@ -117,6 +120,10 @@ impl KeyDeriver { .expect("valid compressed pubkey"); let p2wpkh = Address::p2wpkh(&compressed_pk, self.network).to_string(); + // P2TR (Taproot, key-path spend with no script tree) + let (x_only_key, _parity) = secp_pubkey.x_only_public_key(); + let p2tr = Address::p2tr(&self.secp, x_only_key, None, self.network).to_string(); + // Decimal representation (big-endian) let private_key_decimal = BigUint::from_bytes_be(&key_bytes).to_string(); @@ -161,6 +168,7 @@ impl KeyDeriver { p2pkh_compressed, p2pkh_uncompressed, p2wpkh, + p2tr, } } } @@ -206,10 +214,11 @@ mod tests { let derived = deriver.derive(&key); let addrs = derived.addresses(); - assert_eq!(addrs.len(), 3); + assert_eq!(addrs.len(), 4); assert!(addrs[0].starts_with('1')); // P2PKH compressed assert!(addrs[1].starts_with('1')); // P2PKH uncompressed assert!(addrs[2].starts_with("bc1q")); // P2WPKH + assert!(addrs[3].starts_with("bc1p")); // P2TR } #[test] diff --git a/src/matcher.rs b/src/matcher.rs index 8726df3..02f8399 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -23,6 +23,7 @@ pub enum AddressType { P2pkhCompressed, P2pkhUncompressed, P2wpkh, + P2tr, } impl AddressType { @@ -31,6 +32,7 @@ impl AddressType { AddressType::P2pkhCompressed => "p2pkh_compressed", AddressType::P2pkhUncompressed => "p2pkh_uncompressed", AddressType::P2wpkh => "p2wpkh", + AddressType::P2tr => "p2tr", } } } @@ -94,6 +96,14 @@ impl Matcher { }); } + // Check P2TR (Taproot) + if self.targets.contains(&derived.p2tr) { + return Some(MatchInfo { + address_type: AddressType::P2tr, + address: derived.p2tr.clone(), + }); + } + None } @@ -137,6 +147,22 @@ mod tests { assert_eq!(info.address, "1JwSSubhmg6iPtRjtyqhUYYH7bZg3Lfy1T"); } + #[test] + fn test_matcher_p2tr() { + let key = [1u8; 32]; + let deriver = KeyDeriver::new(); + let derived = deriver.derive(&key); + + let matcher = Matcher::from_addresses(vec![derived.p2tr.clone()]); + + let result = matcher.check(&derived); + assert!(result.is_some()); + + let info = result.unwrap(); + assert!(matches!(info.address_type, AddressType::P2tr)); + assert!(info.address.starts_with("bc1p")); + } + #[test] fn test_matcher_no_match() { let key = [1u8; 32]; diff --git a/src/output/console.rs b/src/output/console.rs index 97d63b5..46b77f7 100644 --- a/src/output/console.rs +++ b/src/output/console.rs @@ -72,6 +72,7 @@ impl Output for ConsoleOutput { writeln!(w, "p2pkh_compressed: {}", derived.p2pkh_compressed)?; writeln!(w, "p2pkh_uncompressed: {}", derived.p2pkh_uncompressed)?; writeln!(w, "p2wpkh: {}", derived.p2wpkh)?; + writeln!(w, "p2tr: {}", derived.p2tr)?; } else { // Compact format: source,transform,privkey,address_compressed writeln!( @@ -115,6 +116,7 @@ impl Output for ConsoleOutput { writeln!(w, "P2PKH (compressed): {}", derived.p2pkh_compressed)?; writeln!(w, "P2PKH (uncompressed): {}", derived.p2pkh_uncompressed)?; writeln!(w, "P2WPKH: {}", derived.p2wpkh)?; + writeln!(w, "P2TR: {}", derived.p2tr)?; writeln!(w, "=========================")?; Ok(()) @@ -165,6 +167,7 @@ mod tests { p2pkh_compressed: "1Address".to_string(), p2pkh_uncompressed: "1Uncompressed".to_string(), p2wpkh: "bc1q...".to_string(), + p2tr: "bc1p...".to_string(), } } diff --git a/src/output/multi.rs b/src/output/multi.rs index 3d88e55..cb45a39 100644 --- a/src/output/multi.rs +++ b/src/output/multi.rs @@ -92,6 +92,7 @@ mod tests { p2pkh_compressed: "1ABC".to_string(), p2pkh_uncompressed: "1DEF".to_string(), p2wpkh: "bc1q".to_string(), + p2tr: "bc1p".to_string(), } } diff --git a/src/output/storage.rs b/src/output/storage.rs index 628481c..85212d3 100644 --- a/src/output/storage.rs +++ b/src/output/storage.rs @@ -185,6 +185,10 @@ impl StorageOutput { address_type: "p2wpkh", address: &derived.p2wpkh, }, + AddressRecord { + address_type: "p2tr", + address: &derived.p2tr, + }, ]; let export_formats = [ @@ -276,6 +280,7 @@ mod tests { p2pkh_compressed: "1ABC123".to_string(), p2pkh_uncompressed: "1DEF456".to_string(), p2wpkh: "bc1qtest".to_string(), + p2tr: "bc1ptest".to_string(), } } diff --git a/src/storage/iceberg/schema.rs b/src/storage/iceberg/schema.rs index 5572fde..f2e4e37 100644 --- a/src/storage/iceberg/schema.rs +++ b/src/storage/iceberg/schema.rs @@ -114,6 +114,12 @@ pub fn build_iceberg_schema() -> Result { Type::Primitive(PrimitiveType::String), ) .into(), + NestedField::optional( + next_id(), + fields::ADDRESS_P2TR, + Type::Primitive(PrimitiveType::String), + ) + .into(), NestedField::optional( next_id(), fields::WIF_COMPRESSED, @@ -150,7 +156,7 @@ mod tests { #[test] fn build_schema_succeeds() { let schema = build_iceberg_schema().unwrap(); - assert_eq!(schema.as_struct().fields().len(), 19); + assert_eq!(schema.as_struct().fields().len(), 20); } #[test] diff --git a/src/storage/parquet_backend.rs b/src/storage/parquet_backend.rs index d49f28b..bf74d1f 100644 --- a/src/storage/parquet_backend.rs +++ b/src/storage/parquet_backend.rs @@ -337,7 +337,7 @@ mod tests { fn schema_returns_result_schema() { let backend = ParquetBackend::new("results", "sha256"); let schema = backend.schema(); - assert_eq!(schema.fields().len(), 19); + assert_eq!(schema.fields().len(), 20); assert_eq!(schema.field(0).name(), "source"); } @@ -581,7 +581,7 @@ mod tests { let batch = &batches[0]; assert_eq!(batch.num_rows(), 2); - assert_eq!(batch.num_columns(), 19); + assert_eq!(batch.num_columns(), 20); let source_col = batch .column(0) diff --git a/src/storage/schema.rs b/src/storage/schema.rs index 81e7783..e4c3c47 100644 --- a/src/storage/schema.rs +++ b/src/storage/schema.rs @@ -40,6 +40,7 @@ pub mod fields { pub const ADDRESS_P2PKH_COMPRESSED: &str = "address_p2pkh_compressed"; pub const ADDRESS_P2PKH_UNCOMPRESSED: &str = "address_p2pkh_uncompressed"; pub const ADDRESS_P2WPKH: &str = "address_p2wpkh"; + pub const ADDRESS_P2TR: &str = "address_p2tr"; // Export formats pub const WIF_COMPRESSED: &str = "wif_compressed"; @@ -52,7 +53,7 @@ pub mod fields { /// Variable-length fields (public_keys, addresses, export_formats) are mapped /// to fixed columns based on known Bitcoin formats. /// -/// # Schema (19 columns) +/// # Schema (20 columns) /// /// | Column | Type | Nullable | Description | /// |--------|------|----------|-------------| @@ -73,6 +74,7 @@ pub mod fields { /// | address_p2pkh_compressed | Utf8 | Yes | P2PKH (compressed) | /// | address_p2pkh_uncompressed | Utf8 | Yes | P2PKH (uncompressed) | /// | address_p2wpkh | Utf8 | Yes | P2WPKH (native segwit) | +/// | address_p2tr | Utf8 | Yes | P2TR (Taproot) | /// | wif_compressed | Utf8 | Yes | WIF compressed | /// | wif_uncompressed | Utf8 | Yes | WIF uncompressed | pub fn result_schema() -> Schema { @@ -106,6 +108,7 @@ pub fn result_schema() -> Schema { Field::new(fields::ADDRESS_P2PKH_COMPRESSED, DataType::Utf8, true), Field::new(fields::ADDRESS_P2PKH_UNCOMPRESSED, DataType::Utf8, true), Field::new(fields::ADDRESS_P2WPKH, DataType::Utf8, true), + Field::new(fields::ADDRESS_P2TR, DataType::Utf8, true), // Export formats (nullable) Field::new(fields::WIF_COMPRESSED, DataType::Utf8, true), Field::new(fields::WIF_UNCOMPRESSED, DataType::Utf8, true), @@ -232,6 +235,12 @@ pub fn records_to_batch(records: &[ResultRecord<'_>]) -> Result>(), )); + let address_p2tr_array: ArrayRef = Arc::new(StringArray::from( + records + .iter() + .map(|r| find_address(r.addresses, "p2tr")) + .collect::>(), + )); // Export format arrays (nullable) let wif_compressed_array: ArrayRef = Arc::new(StringArray::from( @@ -267,6 +276,7 @@ pub fn records_to_batch(records: &[ResultRecord<'_>]) -> Result() .unwrap(); @@ -558,7 +569,7 @@ mod tests { assert!(addr_p2pkh.is_null(0)); let wif = batch - .column(17) + .column(18) .as_any() .downcast_ref::() .unwrap(); From 1e8910a9606cd41ffa261b1ab85d584a11b064c2 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:48:38 +0100 Subject: [PATCH 2/2] fix(storage): append P2TR field after WIF in Iceberg schema Iceberg identifies columns by field ID, not position. Inserting P2TR before the WIF fields would shift their IDs and break reads on existing tables. Append it at the end so WIF keeps IDs 18/19 and P2TR gets the fresh ID 20. --- src/storage/iceberg/schema.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/storage/iceberg/schema.rs b/src/storage/iceberg/schema.rs index f2e4e37..28a9d8b 100644 --- a/src/storage/iceberg/schema.rs +++ b/src/storage/iceberg/schema.rs @@ -116,19 +116,20 @@ pub fn build_iceberg_schema() -> Result { .into(), NestedField::optional( next_id(), - fields::ADDRESS_P2TR, + fields::WIF_COMPRESSED, Type::Primitive(PrimitiveType::String), ) .into(), NestedField::optional( next_id(), - fields::WIF_COMPRESSED, + fields::WIF_UNCOMPRESSED, Type::Primitive(PrimitiveType::String), ) .into(), + // Appended after WIF fields to preserve existing field IDs NestedField::optional( next_id(), - fields::WIF_UNCOMPRESSED, + fields::ADDRESS_P2TR, Type::Primitive(PrimitiveType::String), ) .into(),