From 8de2a3ec9192b7968dff41c703914c3946c83ac7 Mon Sep 17 00:00:00 2001 From: Zeke Date: Thu, 28 May 2026 16:17:17 -0400 Subject: [PATCH 1/5] feat(oracle): add PowAttestOracleClient skeleton --- ddk/src/oracle/pow_attest.rs | 224 +++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 ddk/src/oracle/pow_attest.rs diff --git a/ddk/src/oracle/pow_attest.rs b/ddk/src/oracle/pow_attest.rs new file mode 100644 index 00000000..ed03cc21 --- /dev/null +++ b/ddk/src/oracle/pow_attest.rs @@ -0,0 +1,224 @@ +//! HTTP client for the pow-attest oracle at . +//! +//! pow-attest is a PoW-gated Schnorr attestation oracle implementing the +//! dlcspecs OracleAnnouncement (type 55332) and OracleAttestation (type 55400) +//! TLV formats. Bytes are read directly through +//! `ddk_messages::oracle_msgs::OracleAnnouncement::read` / `OracleAttestation::read` +//! with no JSON translation layer. +//! +//! See discussion at . + +use std::io::Cursor; +use std::sync::Arc; + +use crate::error::OracleError; +use crate::logger::{log_error, log_info, Logger, WriteLog}; +use bitcoin::key::XOnlyPublicKey; +use ddk_messages::oracle_msgs::{OracleAnnouncement, OracleAttestation}; +use lightning::util::ser::Readable; +use serde::Deserialize; + +/// Client for the pow-attest oracle. +/// +/// The oracle exposes binary TLV endpoints that match the dlcspecs format +/// verbatim, so deserialization uses `lightning::util::ser::Readable` on the +/// response payload after stripping the outer TLV header. +#[derive(Debug)] +pub struct PowAttestOracleClient { + host: String, + pubkey: XOnlyPublicKey, + client: reqwest::Client, + logger: Arc, +} + +#[derive(Debug, Deserialize)] +struct InfoResponse { + oracle_pubkey: XOnlyPublicKey, +} + +impl PowAttestOracleClient { + /// Connects to a pow-attest oracle at `host` (e.g. `https://attest.powforge.dev`). + /// + /// Fetches `/api/v1/info` to learn the oracle's x-only public key. + pub async fn new(host: &str, logger: Arc) -> Result { + if host.is_empty() { + return Err(OracleError::Init("Invalid host".to_string())); + } + let host = host.trim_end_matches('/').to_string(); + let client = reqwest::Client::new(); + let info: InfoResponse = client + .get(format!("{host}/api/v1/info")) + .send() + .await + .map_err(|e| { + OracleError::Init(format!("Could not reach pow-attest: {e}")) + })? + .json() + .await + .map_err(|e| { + OracleError::Init(format!("Could not parse /api/v1/info: {e}")) + })?; + log_info!( + logger, + "Connected to pow-attest oracle. host={} pubkey={}", + host, + info.oracle_pubkey + ); + Ok(Self { + host, + pubkey: info.oracle_pubkey, + client, + logger, + }) + } +} + +/// Strips the outer TLV header (BigSize type + BigSize length) from the +/// response body and reads the inner payload. +/// +/// The pow-attest endpoints return the full TLV wire format including the +/// 3-byte BigSize type (`fdd824` for announcements, `fdd868` for attestations) +/// and the 1-byte BigSize length prefix. `OracleAnnouncement::read` and +/// `OracleAttestation::read` expect only the payload, so the leading 4 bytes +/// are skipped here. +fn read_tlv_payload(bytes: &[u8]) -> Result { + // Outer type+len wrapper is 4 bytes for the message sizes pow-attest emits + // (3-byte BigSize type + 1-byte BigSize length <= 252). If the server ever + // grows a message past 252 bytes of payload, the length prefix becomes + // multi-byte and this offset will need to follow the BigSize length rules + // in dlcspecs. + let payload = if bytes.len() > 4 { &bytes[4..] } else { bytes }; + let mut cur = Cursor::new(payload); + T::read(&mut cur) +} + +#[async_trait::async_trait] +impl ddk_manager::Oracle for PowAttestOracleClient { + fn get_public_key(&self) -> XOnlyPublicKey { + self.pubkey + } + + #[tracing::instrument(skip(self))] + async fn get_announcement( + &self, + event_id: &str, + ) -> Result { + let url = format!( + "{}/api/v1/bounty/{}/announcement.tlv", + self.host, event_id + ); + let bytes = self + .client + .get(&url) + .send() + .await + .map_err(|e| { + log_error!( + self.logger, + "Could not fetch pow-attest announcement. error={}", + e + ); + ddk_manager::error::Error::OracleError(format!( + "Could not fetch announcement: {e}" + )) + })? + .bytes() + .await + .map_err(|e| { + ddk_manager::error::Error::OracleError(format!( + "Could not read announcement body: {e}" + )) + })?; + let announcement = read_tlv_payload::(&bytes).map_err(|e| { + log_error!( + self.logger, + "Could not decode pow-attest announcement TLV. error={:?}", + e + ); + ddk_manager::error::Error::OracleError(format!("Could not decode announcement: {e:?}")) + })?; + log_info!( + self.logger, + "pow-attest announcement. event_id={} nonces={}", + event_id, + announcement.oracle_event.oracle_nonces.len() + ); + Ok(announcement) + } + + #[tracing::instrument(skip(self))] + async fn get_attestation( + &self, + event_id: &str, + ) -> Result { + let url = format!( + "{}/api/v1/bounty/{}/attestation.tlv", + self.host, event_id + ); + let bytes = self + .client + .get(&url) + .send() + .await + .map_err(|e| { + log_error!( + self.logger, + "Could not fetch pow-attest attestation. error={}", + e + ); + ddk_manager::error::Error::OracleError(format!( + "Could not fetch attestation: {e}" + )) + })? + .bytes() + .await + .map_err(|e| { + ddk_manager::error::Error::OracleError(format!( + "Could not read attestation body: {e}" + )) + })?; + let attestation = read_tlv_payload::(&bytes).map_err(|e| { + log_error!( + self.logger, + "Could not decode pow-attest attestation TLV. error={:?}", + e + ); + ddk_manager::error::Error::OracleError(format!("Could not decode attestation: {e:?}")) + })?; + log_info!( + self.logger, + "pow-attest attestation. event_id={} outcomes={:?}", + event_id, + attestation.outcomes + ); + Ok(attestation) + } +} + +impl crate::Oracle for PowAttestOracleClient { + fn name(&self) -> String { + "pow-attest".into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Captured OracleAnnouncement TLV from + /// `https://attest.powforge.dev/api/v1/bounty//announcement.tlv`. + /// + /// The 4-byte outer header is `fdd824 c9`: + /// - `fdd824` = 3-byte BigSize for type `55332` (OracleAnnouncement) + /// - `c9` = 1-byte BigSize for length `201` + const STATIC_ANNOUNCEMENT_TLV_HEX: &str = "fdd824c9711cd782ddf632840c17b934e646785eb5418ec1b104436cce98eff8a4ea1557cd5d2e93316d300aa758cefebf02dd23f9a0fdfe08ce807e9b54ac241c80243def6218b2e12d74ffafa1b6e5217cc4592848c321c28109869903ff88989db23bfdd8226500013e0c2dad9737a8fc69f09298317fae26276c6319f65f0c589e57973abf48fbd967352480fdd806150002000852454c4541534544000750454e44494e47002436626137623831302d396461642d313164312d383062342d303063303466643433306338"; + + #[test] + fn roundtrips_static_announcement() { + let bytes = hex::decode(STATIC_ANNOUNCEMENT_TLV_HEX).expect("hex"); + let ann = read_tlv_payload::(&bytes) + .expect("OracleAnnouncement::read failed on captured pow-attest TLV"); + assert_eq!(ann.oracle_event.oracle_nonces.len(), 1); + assert!(ann.oracle_event.event_id.contains("6ba7b810")); + } +} From 66e59b078ce57bb964691512262f15c92d54c638 Mon Sep 17 00:00:00 2001 From: Zeke Date: Thu, 28 May 2026 16:18:33 -0400 Subject: [PATCH 2/5] feat(oracle): gate pow_attest mod under pow-attest feature --- ddk/src/oracle/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ddk/src/oracle/mod.rs b/ddk/src/oracle/mod.rs index 0bfefc18..87958936 100644 --- a/ddk/src/oracle/mod.rs +++ b/ddk/src/oracle/mod.rs @@ -5,3 +5,5 @@ pub mod memory; pub mod nostr; #[cfg(feature = "p2pderivatives")] pub mod p2p_derivatives; +#[cfg(feature = "pow-attest")] +pub mod pow_attest; From f7c5e77dc2f522b4dcff56adf795324fe4546ee1 Mon Sep 17 00:00:00 2001 From: Zeke Date: Thu, 28 May 2026 16:21:58 -0400 Subject: [PATCH 3/5] feat(oracle): add pow-attest feature flag + example registration --- ddk/Cargo.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ddk/Cargo.toml b/ddk/Cargo.toml index 9a449b01..eaaf7a83 100644 --- a/ddk/Cargo.toml +++ b/ddk/Cargo.toml @@ -3,7 +3,7 @@ name = "ddk" version = "1.0.11" edition = "2021" license = "MIT" -description = "application tooling for DLCs 🌊" +description = "application tooling for DLCs 🌊" documentation = "https://docs.rs/crate/ddk" repository = "https://github.com/bennyhodl/dlcdevkit" homepage = "https://dlcdevkit.com" @@ -18,6 +18,7 @@ lightning = ["dep:lightning-net-tokio"] kormir = ["dep:reqwest"] p2pderivatives = ["dep:reqwest"] nostr-oracle = ["dep:nostr-database", "nostr", "kormir", "kormir/nostr"] +pow-attest = ["dep:reqwest"] # storage features sled = ["dep:sled"] @@ -87,3 +88,8 @@ required-features = ["nostr"] name = "postgres" path = "examples/postgres.rs" required-features = ["postgres", "lightning", "kormir"] + +[[example]] +name = "pow_attest" +path = "examples/pow_attest.rs" +required-features = ["pow-attest"] From c6eb7c6ff0a3a1fc4ad97836692bea1ae286be7a Mon Sep 17 00:00:00 2001 From: Zeke Date: Thu, 28 May 2026 16:23:34 -0400 Subject: [PATCH 4/5] feat(examples): add pow_attest example targeting attest.powforge.dev --- ddk/examples/pow_attest.rs | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 ddk/examples/pow_attest.rs diff --git a/ddk/examples/pow_attest.rs b/ddk/examples/pow_attest.rs new file mode 100644 index 00000000..79c3e0bb --- /dev/null +++ b/ddk/examples/pow_attest.rs @@ -0,0 +1,50 @@ +//! Connects to the live pow-attest oracle and prints a decoded +//! `OracleAnnouncement`. +//! +//! Run with: +//! +//! ```bash +//! cargo run --example pow_attest --features pow-attest +//! ``` +//! +//! The default event id below is the static bounty kept on the pow-attest +//! server for downstream-test purposes. Override with the `EVENT_ID` +//! environment variable to point at any registered bounty. + +use std::sync::Arc; + +use ddk::error::Error; +use ddk::logger::{LogLevel, Logger}; +use ddk::oracle::pow_attest::PowAttestOracleClient; + +const DEFAULT_HOST: &str = "https://attest.powforge.dev"; +const DEFAULT_EVENT_ID: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let logger = Arc::new(Logger::console( + "pow_attest_example".to_string(), + LogLevel::Info, + )); + + let host = std::env::var("POW_ATTEST_HOST").unwrap_or_else(|_| DEFAULT_HOST.to_string()); + let event_id = + std::env::var("EVENT_ID").unwrap_or_else(|_| DEFAULT_EVENT_ID.to_string()); + + let client = PowAttestOracleClient::new(&host, logger).await?; + + // ddk_manager::Oracle is the trait that exposes get_announcement / + // get_attestation. Bring it into scope so the methods resolve. + use ddk_manager::Oracle as _; + + let announcement = client.get_announcement(&event_id).await?; + println!("oracle_pubkey: {}", announcement.oracle_public_key); + println!("event_id: {}", announcement.oracle_event.event_id); + println!("nonces: {}", announcement.oracle_event.oracle_nonces.len()); + println!( + "maturity_epoch: {}", + announcement.oracle_event.event_maturity_epoch + ); + + Ok(()) +} From 9a790c1f3fb76d13ed61c966ca3a591159040efb Mon Sep 17 00:00:00 2001 From: zekebuilds-lab Date: Mon, 1 Jun 2026 18:35:22 -0400 Subject: [PATCH 5/5] fix: use ddk_messages::ser_impls::read_as_tlv per review Replace local read_tlv_payload with the existing read_as_tlv helper from ddk-messages as requested by @bennyhodl. read_as_tlv reads BigSize type and BigSize length fields correctly regardless of payload size, where the previous implementation hardcoded a 4-byte skip. --- ddk/src/oracle/pow_attest.rs | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/ddk/src/oracle/pow_attest.rs b/ddk/src/oracle/pow_attest.rs index ed03cc21..6bc932d3 100644 --- a/ddk/src/oracle/pow_attest.rs +++ b/ddk/src/oracle/pow_attest.rs @@ -15,14 +15,14 @@ use crate::error::OracleError; use crate::logger::{log_error, log_info, Logger, WriteLog}; use bitcoin::key::XOnlyPublicKey; use ddk_messages::oracle_msgs::{OracleAnnouncement, OracleAttestation}; -use lightning::util::ser::Readable; +use ddk_messages::ser_impls::read_as_tlv; use serde::Deserialize; /// Client for the pow-attest oracle. /// /// The oracle exposes binary TLV endpoints that match the dlcspecs format -/// verbatim, so deserialization uses `lightning::util::ser::Readable` on the -/// response payload after stripping the outer TLV header. +/// verbatim. Responses are deserialized with `ddk_messages::ser_impls::read_as_tlv`, +/// which reads the BigSize type + BigSize length prefix before decoding the payload. #[derive(Debug)] pub struct PowAttestOracleClient { host: String, @@ -73,25 +73,6 @@ impl PowAttestOracleClient { } } -/// Strips the outer TLV header (BigSize type + BigSize length) from the -/// response body and reads the inner payload. -/// -/// The pow-attest endpoints return the full TLV wire format including the -/// 3-byte BigSize type (`fdd824` for announcements, `fdd868` for attestations) -/// and the 1-byte BigSize length prefix. `OracleAnnouncement::read` and -/// `OracleAttestation::read` expect only the payload, so the leading 4 bytes -/// are skipped here. -fn read_tlv_payload(bytes: &[u8]) -> Result { - // Outer type+len wrapper is 4 bytes for the message sizes pow-attest emits - // (3-byte BigSize type + 1-byte BigSize length <= 252). If the server ever - // grows a message past 252 bytes of payload, the length prefix becomes - // multi-byte and this offset will need to follow the BigSize length rules - // in dlcspecs. - let payload = if bytes.len() > 4 { &bytes[4..] } else { bytes }; - let mut cur = Cursor::new(payload); - T::read(&mut cur) -} - #[async_trait::async_trait] impl ddk_manager::Oracle for PowAttestOracleClient { fn get_public_key(&self) -> XOnlyPublicKey { @@ -129,7 +110,7 @@ impl ddk_manager::Oracle for PowAttestOracleClient { "Could not read announcement body: {e}" )) })?; - let announcement = read_tlv_payload::(&bytes).map_err(|e| { + let announcement = read_as_tlv::(&mut Cursor::new(&bytes)).map_err(|e| { log_error!( self.logger, "Could not decode pow-attest announcement TLV. error={:?}", @@ -177,7 +158,7 @@ impl ddk_manager::Oracle for PowAttestOracleClient { "Could not read attestation body: {e}" )) })?; - let attestation = read_tlv_payload::(&bytes).map_err(|e| { + let attestation = read_as_tlv::(&mut Cursor::new(&bytes)).map_err(|e| { log_error!( self.logger, "Could not decode pow-attest attestation TLV. error={:?}", @@ -216,7 +197,7 @@ mod tests { #[test] fn roundtrips_static_announcement() { let bytes = hex::decode(STATIC_ANNOUNCEMENT_TLV_HEX).expect("hex"); - let ann = read_tlv_payload::(&bytes) + let ann = read_as_tlv::(&mut Cursor::new(&bytes)) .expect("OracleAnnouncement::read failed on captured pow-attest TLV"); assert_eq!(ann.oracle_event.oracle_nonces.len(), 1); assert!(ann.oracle_event.event_id.contains("6ba7b810"));