From: Matt Corallo Date: Sun, 14 Jul 2024 13:08:04 +0000 (+0000) Subject: Add a `lightning-dns-resolver` crate which answers bLIP 32 queries X-Git-Url: http://git.bitcoin.ninja/index.cgi?a=commitdiff_plain;h=8e941426cfc6588e445bdece7494a89e0b162823;p=rust-lightning Add a `lightning-dns-resolver` crate which answers bLIP 32 queries When a lightning node wishes to send payments to a BIP 353 human readable name (using BOLT 12), it first has to resolve that name to a DNS TXT record. bLIP 32 defines a way to do so over onion messages, and this completes our implementation thereof by adding the server side. It operates by simply accepting new messages and spawning tokio tasks to do DNS lookups using the `dnsse_prover` crate. It also contains full end-to-end tests of the BIP 353 -> BOLT 12 -> payment logic using the new server code to do the resolution. Note that because we now have a workspace crate which sets the "lightning/dnssec" feature in its `dev-dependencies`, a naive `cargo test` will test the "dnssec" feature. --- diff --git a/.gitignore b/.gitignore index fbeffa8a9..8507aea83 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,6 @@ lightning/net_graph-*.bin lightning-rapid-gossip-sync/res/full_graph.lngossip lightning-custom-message/target lightning-transaction-sync/target +lightning-dns-resolver/target no-std-check/target msrv-no-dev-deps-check/target diff --git a/Cargo.toml b/Cargo.toml index f0f09f547..b4ba58bac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "lightning-custom-message", "lightning-transaction-sync", "lightning-macros", + "lightning-dns-resolver", "possiblyrandom", ] diff --git a/ci/ci-tests.sh b/ci/ci-tests.sh index 406fee245..4ec484e77 100755 --- a/ci/ci-tests.sh +++ b/ci/ci-tests.sh @@ -54,6 +54,7 @@ WORKSPACE_MEMBERS=( lightning-custom-message lightning-transaction-sync lightning-macros + lightning-dns-resolver possiblyrandom ) @@ -64,10 +65,6 @@ for DIR in "${WORKSPACE_MEMBERS[@]}"; do cargo doc -p "$DIR" --document-private-items done -echo -e "\n\nChecking and testing lightning crate with dnssec feature" -cargo test -p lightning --verbose --color always --features dnssec -cargo check -p lightning --verbose --color always --features dnssec - echo -e "\n\nChecking and testing Block Sync Clients with features" cargo test -p lightning-block-sync --verbose --color always --features rest-client diff --git a/lightning-dns-resolver/Cargo.toml b/lightning-dns-resolver/Cargo.toml new file mode 100644 index 000000000..14304678d --- /dev/null +++ b/lightning-dns-resolver/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lightning-dns-resolver" +version = "0.1.0" +authors = ["Matt Corallo"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/lightningdevkit/rust-lightning/" +description = "A crate which implements DNSSEC resolution for lightning clients over bLIP 32 using `tokio` and the `dnssec-prover` crate." +edition = "2021" + +[dependencies] +lightning = { version = "0.0.124", path = "../lightning", default-features = false } +dnssec-prover = { version = "0.6", default-features = false, features = [ "std", "tokio" ] } +tokio = { version = "1.0", default-features = false, features = ["rt"] } + +[dev-dependencies] +bitcoin = { version = "0.32" } +tokio = { version = "1.0", default-features = false, features = ["macros", "time"] } +lightning = { version = "0.0.124", path = "../lightning", features = ["dnssec", "_test_utils"] } diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs new file mode 100644 index 000000000..83a2adedc --- /dev/null +++ b/lightning-dns-resolver/src/lib.rs @@ -0,0 +1,454 @@ +//! A simple crate which uses [`dnssec_prover`] to create DNSSEC Proofs in response to bLIP 32 +//! Onion Message DNSSEC Proof Queries. + +#![deny(missing_docs)] +#![deny(rustdoc::broken_intra_doc_links)] +#![deny(rustdoc::private_intra_doc_links)] + +use std::net::SocketAddr; +use std::ops::Deref; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use dnssec_prover::query::build_txt_proof_async; + +use lightning::blinded_path::message::DNSResolverContext; +use lightning::ln::peer_handler::IgnoringMessageHandler; +use lightning::onion_message::dns_resolution::{ + DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, +}; +use lightning::onion_message::messenger::{ + MessageSendInstructions, Responder, ResponseInstruction, +}; + +use tokio::runtime::Handle; + +#[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))] +const WE_REQUIRE_32_OR_64_BIT_USIZE: u8 = 424242; + +/// A resolver which implements [`DNSResolverMessageHandler`] and replies to [`DNSSECQuery`] +/// messages with with [`DNSSECProof`]s. +pub struct OMDomainResolver +where + PH::Target: DNSResolverMessageHandler, +{ + state: Arc, + proof_handler: Option, + runtime_handle: Mutex>, +} + +const MAX_PENDING_RESPONSES: usize = 1024; +struct OMResolverState { + resolver: SocketAddr, + pending_replies: Mutex>, + pending_query_count: AtomicUsize, +} + +impl OMDomainResolver { + /// Creates a new [`OMDomainResolver`] given the [`SocketAddr`] of a DNS resolver listening on + /// TCP (e.g. 8.8.8.8:53, 1.1.1.1:53 or your local DNS resolver). + /// + /// Ignores any incoming [`DNSSECProof`] messages. + pub fn ignoring_incoming_proofs(resolver: SocketAddr) -> Self { + Self::new(resolver, None) + } +} + +impl OMDomainResolver +where + PH::Target: DNSResolverMessageHandler, +{ + /// Creates a new [`OMDomainResolver`] given the [`SocketAddr`] of a DNS resolver listening on + /// TCP (e.g. 8.8.8.8:53, 1.1.1.1:53 or your local DNS resolver). + /// + /// Uses `tokio`'s [`Handle::current`] to fetch the async runtime on which futures will be + /// spawned. + /// + /// The optional `proof_handler` can be provided to pass proofs coming back to us to the + /// underlying handler. This is useful when this resolver is handling incoming resolution + /// requests but some other handler is making proof requests of remote nodes and wants to get + /// results. + pub fn new(resolver: SocketAddr, proof_handler: Option) -> Self { + Self::with_runtime(resolver, proof_handler, Some(Handle::current())) + } + + /// Creates a new [`OMDomainResolver`] given the [`SocketAddr`] of a DNS resolver listening on + /// TCP (e.g. 8.8.8.8:53, 1.1.1.1:53 or your local DNS resolver) and a `tokio` runtime + /// [`Handle`] on which futures will be spawned. If no runtime is provided, `set_runtime` must + /// be called before any queries will be handled. + /// + /// The optional `proof_handler` can be provided to pass proofs coming back to us to the + /// underlying handler. This is useful when this resolver is handling incoming resolution + /// requests but some other handler is making proof requests of remote nodes and wants to get + /// results. + pub fn with_runtime( + resolver: SocketAddr, proof_handler: Option, runtime_handle: Option, + ) -> Self { + Self { + state: Arc::new(OMResolverState { + resolver, + pending_replies: Mutex::new(Vec::new()), + pending_query_count: AtomicUsize::new(0), + }), + proof_handler, + runtime_handle: Mutex::new(runtime_handle), + } + } + + /// Sets the runtime on which futures will be spawned. + pub fn set_runtime(&self, runtime_handle: Handle) { + *self.runtime_handle.lock().unwrap() = Some(runtime_handle); + } +} + +impl DNSResolverMessageHandler for OMDomainResolver +where + PH::Target: DNSResolverMessageHandler, +{ + fn handle_dnssec_proof(&self, proof: DNSSECProof, context: DNSResolverContext) { + if let Some(proof_handler) = &self.proof_handler { + proof_handler.handle_dnssec_proof(proof, context); + } + } + + fn handle_dnssec_query( + &self, q: DNSSECQuery, responder_opt: Option, + ) -> Option<(DNSResolverMessage, ResponseInstruction)> { + let responder = match responder_opt { + Some(responder) => responder, + None => return None, + }; + let runtime = if let Some(runtime) = self.runtime_handle.lock().unwrap().clone() { + runtime + } else { + return None; + }; + if self.state.pending_query_count.fetch_add(1, Ordering::Relaxed) > MAX_PENDING_RESPONSES { + self.state.pending_query_count.fetch_sub(1, Ordering::Relaxed); + return None; + } + let us = Arc::clone(&self.state); + runtime.spawn(async move { + if let Ok((proof, _ttl)) = build_txt_proof_async(us.resolver, &q.0).await { + let contents = DNSResolverMessage::DNSSECProof(DNSSECProof { name: q.0, proof }); + let instructions = responder.respond().into_instructions(); + us.pending_replies.lock().unwrap().push((contents, instructions)); + us.pending_query_count.fetch_sub(1, Ordering::Relaxed); + } + }); + None + } + + fn release_pending_messages(&self) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { + core::mem::take(&mut *self.state.pending_replies.lock().unwrap()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; + use bitcoin::Block; + + use lightning::blinded_path::message::{BlindedMessagePath, MessageContext}; + use lightning::blinded_path::NodeIdLookUp; + use lightning::events::{Event, PaymentPurpose}; + use lightning::ln::channelmanager::{PaymentId, Retry}; + use lightning::ln::functional_test_utils::*; + use lightning::ln::msgs::{ChannelMessageHandler, Init, OnionMessageHandler}; + use lightning::ln::peer_handler::IgnoringMessageHandler; + use lightning::onion_message::dns_resolution::{HumanReadableName, OMNameResolver}; + use lightning::onion_message::messenger::{ + AOnionMessenger, Destination, MessageRouter, OnionMessagePath, OnionMessenger, + }; + use lightning::sign::{KeysManager, NodeSigner, Recipient}; + use lightning::types::features::InitFeatures; + use lightning::types::payment::PaymentHash; + use lightning::util::logger::Logger; + + use lightning::{ + commitment_signed_dance, expect_payment_claimed, expect_pending_htlcs_forwardable, + get_htlc_update_msgs, + }; + + use std::ops::Deref; + use std::sync::Mutex; + use std::time::{Duration, Instant, SystemTime}; + + struct TestLogger { + node: &'static str, + } + impl Logger for TestLogger { + fn log(&self, record: lightning::util::logger::Record) { + eprintln!("{}: {}", self.node, record.args); + } + } + impl Deref for TestLogger { + type Target = TestLogger; + fn deref(&self) -> &TestLogger { + self + } + } + + struct DummyNodeLookup {} + impl NodeIdLookUp for DummyNodeLookup { + fn next_node_id(&self, _: u64) -> Option { + None + } + } + impl Deref for DummyNodeLookup { + type Target = DummyNodeLookup; + fn deref(&self) -> &DummyNodeLookup { + self + } + } + + struct DirectlyConnectedRouter {} + impl MessageRouter for DirectlyConnectedRouter { + fn find_path( + &self, _sender: PublicKey, _peers: Vec, destination: Destination, + ) -> Result { + Ok(OnionMessagePath { + destination, + first_node_addresses: None, + intermediate_nodes: Vec::new(), + }) + } + + fn create_blinded_paths( + &self, recipient: PublicKey, context: MessageContext, _peers: Vec, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + let keys = KeysManager::new(&[0; 32], 42, 43); + Ok(vec![BlindedMessagePath::one_hop(recipient, context, &keys, secp_ctx).unwrap()]) + } + } + impl Deref for DirectlyConnectedRouter { + type Target = DirectlyConnectedRouter; + fn deref(&self) -> &DirectlyConnectedRouter { + self + } + } + + struct URIResolver { + resolved_uri: Mutex>, + resolver: OMNameResolver, + pending_messages: Mutex>, + } + impl DNSResolverMessageHandler for URIResolver { + fn handle_dnssec_query( + &self, _: DNSSECQuery, _: Option, + ) -> Option<(DNSResolverMessage, ResponseInstruction)> { + panic!(); + } + + fn handle_dnssec_proof(&self, msg: DNSSECProof, context: DNSResolverContext) { + let mut proof = self.resolver.handle_dnssec_proof_for_uri(msg, context).unwrap(); + assert_eq!(proof.0.len(), 1); + let payment = proof.0.pop().unwrap(); + let mut result = Some((payment.0, payment.1, proof.1)); + core::mem::swap(&mut *self.resolved_uri.lock().unwrap(), &mut result); + assert!(result.is_none()); + } + fn release_pending_messages(&self) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { + core::mem::take(&mut *self.pending_messages.lock().unwrap()) + } + } + + fn create_resolver() -> (impl AOnionMessenger, PublicKey) { + let resolver_keys = Arc::new(KeysManager::new(&[99; 32], 42, 43)); + let resolver_logger = TestLogger { node: "resolver" }; + let resolver = OMDomainResolver::ignoring_incoming_proofs("8.8.8.8:53".parse().unwrap()); + let resolver = Arc::new(resolver); + ( + OnionMessenger::new( + Arc::clone(&resolver_keys), + Arc::clone(&resolver_keys), + resolver_logger, + DummyNodeLookup {}, + DirectlyConnectedRouter {}, + IgnoringMessageHandler {}, + IgnoringMessageHandler {}, + Arc::clone(&resolver), + IgnoringMessageHandler {}, + ), + resolver_keys.get_node_id(Recipient::Node).unwrap(), + ) + } + + fn get_om_init() -> Init { + let mut init_msg = + Init { features: InitFeatures::empty(), networks: None, remote_network_address: None }; + init_msg.features.set_onion_messages_optional(); + init_msg + } + + #[tokio::test] + async fn resolution_test() { + let secp_ctx = Secp256k1::new(); + + let (resolver_messenger, resolver_id) = create_resolver(); + + let resolver_dest = Destination::Node(resolver_id); + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + + let payment_id = PaymentId([42; 32]); + let name = HumanReadableName::from_encoded("matt@mattcorallo.com").unwrap(); + + let payer_keys = Arc::new(KeysManager::new(&[2; 32], 42, 43)); + let payer_logger = TestLogger { node: "payer" }; + let payer_id = payer_keys.get_node_id(Recipient::Node).unwrap(); + let payer = Arc::new(URIResolver { + resolved_uri: Mutex::new(None), + resolver: OMNameResolver::new(now as u32, 1), + pending_messages: Mutex::new(Vec::new()), + }); + let payer_messenger = Arc::new(OnionMessenger::new( + Arc::clone(&payer_keys), + Arc::clone(&payer_keys), + payer_logger, + DummyNodeLookup {}, + DirectlyConnectedRouter {}, + IgnoringMessageHandler {}, + IgnoringMessageHandler {}, + Arc::clone(&payer), + IgnoringMessageHandler {}, + )); + + let init_msg = get_om_init(); + payer_messenger.peer_connected(resolver_id, &init_msg, true).unwrap(); + resolver_messenger.get_om().peer_connected(payer_id, &init_msg, false).unwrap(); + + let (msg, context) = + payer.resolver.resolve_name(payment_id, name.clone(), &*payer_keys).unwrap(); + let query_context = MessageContext::DNSResolver(context); + let reply_path = + BlindedMessagePath::one_hop(payer_id, query_context, &*payer_keys, &secp_ctx).unwrap(); + payer.pending_messages.lock().unwrap().push(( + DNSResolverMessage::DNSSECQuery(msg), + MessageSendInstructions::WithSpecifiedReplyPath { + destination: resolver_dest, + reply_path, + }, + )); + + let query = payer_messenger.next_onion_message_for_peer(resolver_id).unwrap(); + resolver_messenger.get_om().handle_onion_message(payer_id, &query); + + assert!(resolver_messenger.get_om().next_onion_message_for_peer(payer_id).is_none()); + let start = Instant::now(); + let response = loop { + tokio::time::sleep(Duration::from_millis(10)).await; + if let Some(msg) = resolver_messenger.get_om().next_onion_message_for_peer(payer_id) { + break msg; + } + assert!(start.elapsed() < Duration::from_secs(10), "Resolution took too long"); + }; + + payer_messenger.handle_onion_message(resolver_id, &response); + let resolution = payer.resolved_uri.lock().unwrap().take().unwrap(); + assert_eq!(resolution.0, name); + assert_eq!(resolution.1, payment_id); + assert!(resolution.2[.."bitcoin:".len()].eq_ignore_ascii_case("bitcoin:")); + } + + #[tokio::test] + async fn end_to_end_test() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes(&nodes, 0, 1); + + // The DNSSEC validation will only work with the current time, so set the time on the + // resolver. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + let block = Block { + header: create_dummy_header(nodes[0].best_block_hash(), now as u32), + txdata: Vec::new(), + }; + connect_block(&nodes[0], &block); + connect_block(&nodes[1], &block); + + let payer_id = nodes[0].node.get_our_node_id(); + let payee_id = nodes[1].node.get_our_node_id(); + + let (resolver_messenger, resolver_id) = create_resolver(); + let init_msg = get_om_init(); + nodes[0].onion_messenger.peer_connected(resolver_id, &init_msg, true).unwrap(); + resolver_messenger.get_om().peer_connected(payer_id, &init_msg, false).unwrap(); + + let name = HumanReadableName::from_encoded("matt@mattcorallo.com").unwrap(); + + // When we get the proof back, override its contents to an offer from nodes[1] + let bs_offer = nodes[1].node.create_offer_builder(None).unwrap().build().unwrap(); + nodes[0] + .node + .testing_dnssec_proof_offer_resolution_override + .lock() + .unwrap() + .insert(name.clone(), bs_offer); + + let payment_id = PaymentId([42; 32]); + let resolvers = vec![Destination::Node(resolver_id)]; + let retry = Retry::Attempts(0); + let amt = 42_000; + nodes[0] + .node + .pay_for_offer_from_human_readable_name(name, amt, payment_id, retry, None, resolvers) + .unwrap(); + + let query = nodes[0].onion_messenger.next_onion_message_for_peer(resolver_id).unwrap(); + resolver_messenger.get_om().handle_onion_message(payer_id, &query); + + assert!(resolver_messenger.get_om().next_onion_message_for_peer(payer_id).is_none()); + let start = Instant::now(); + let response = loop { + tokio::time::sleep(Duration::from_millis(10)).await; + if let Some(msg) = resolver_messenger.get_om().next_onion_message_for_peer(payer_id) { + break msg; + } + assert!(start.elapsed() < Duration::from_secs(10), "Resolution took too long"); + }; + + nodes[0].onion_messenger.handle_onion_message(resolver_id, &response); + + let invreq = nodes[0].onion_messenger.next_onion_message_for_peer(payee_id).unwrap(); + nodes[1].onion_messenger.handle_onion_message(payer_id, &invreq); + + let inv = nodes[1].onion_messenger.next_onion_message_for_peer(payer_id).unwrap(); + nodes[0].onion_messenger.handle_onion_message(payee_id, &inv); + + check_added_monitors(&nodes[0], 1); + let updates = get_htlc_update_msgs!(nodes[0], payee_id); + nodes[1].node.handle_update_add_htlc(payer_id, &updates.update_add_htlcs[0]); + commitment_signed_dance!(nodes[1], nodes[0], updates.commitment_signed, false); + expect_pending_htlcs_forwardable!(nodes[1]); + + let claimable_events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(claimable_events.len(), 1); + let our_payment_preimage; + if let Event::PaymentClaimable { purpose, amount_msat, .. } = &claimable_events[0] { + assert_eq!(*amount_msat, amt); + if let PaymentPurpose::Bolt12OfferPayment { payment_preimage, .. } = purpose { + our_payment_preimage = payment_preimage.unwrap(); + nodes[1].node.claim_funds(our_payment_preimage); + let payment_hash: PaymentHash = our_payment_preimage.into(); + expect_payment_claimed!(nodes[1], payment_hash, amt); + } else { + panic!(); + } + } else { + panic!(); + } + + check_added_monitors(&nodes[1], 1); + let updates = get_htlc_update_msgs!(nodes[1], payer_id); + nodes[0].node.handle_update_fulfill_htlc(payee_id, &updates.update_fulfill_htlcs[0]); + commitment_signed_dance!(nodes[0], nodes[1], updates.commitment_signed, false); + + expect_payment_sent(&nodes[0], our_payment_preimage, None, true, true); + } +} diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8ddb2aa11..9ac084404 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2575,6 +2575,14 @@ where #[cfg(feature = "dnssec")] pending_dns_onion_messages: Mutex>, + #[cfg(feature = "_test_utils")] + /// In testing, it is useful be able to forge a name -> offer mapping so that we can pay an + /// offer generated in the test. + /// + /// This allows for doing so, validating proofs as normal, but, if they pass, replacing the + /// offer they resolve to to the given one. + pub testing_dnssec_proof_offer_resolution_override: Mutex>, + entropy_source: ES, node_signer: NS, signer_provider: SP, @@ -3402,6 +3410,9 @@ where hrn_resolver: OMNameResolver::new(current_timestamp, params.best_block.height), #[cfg(feature = "dnssec")] pending_dns_onion_messages: Mutex::new(Vec::new()), + + #[cfg(feature = "_test_utils")] + testing_dnssec_proof_offer_resolution_override: Mutex::new(new_hash_map()), } } @@ -11760,8 +11771,15 @@ where fn handle_dnssec_proof(&self, message: DNSSECProof, context: DNSResolverContext) { let offer_opt = self.hrn_resolver.handle_dnssec_proof_for_offer(message, context); - if let Some((completed_requests, offer)) = offer_opt { + #[cfg_attr(not(feature = "_test_utils"), allow(unused_mut))] + if let Some((completed_requests, mut offer)) = offer_opt { for (name, payment_id) in completed_requests { + #[cfg(feature = "_test_utils")] + if let Some(replacement_offer) = self.testing_dnssec_proof_offer_resolution_override.lock().unwrap().remove(&name) { + // If we have multiple pending requests we may end up over-using the override + // offer, but tests can deal with that. + offer = replacement_offer; + } if let Ok(amt_msats) = self.pending_outbound_payments.amt_msats_for_payment_awaiting_offer(payment_id) { let offer_pay_res = self.pay_for_offer_intern(&offer, None, Some(amt_msats), None, payment_id, Some(name), @@ -13483,6 +13501,9 @@ where hrn_resolver: OMNameResolver::new(highest_seen_timestamp, best_block_height), #[cfg(feature = "dnssec")] pending_dns_onion_messages: Mutex::new(Vec::new()), + + #[cfg(feature = "_test_utils")] + testing_dnssec_proof_offer_resolution_override: Mutex::new(new_hash_map()), }; for (_, monitor) in args.channel_monitors.iter() { diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 001fa6a49..acac9dc00 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -406,7 +406,9 @@ pub struct ResponseInstruction { } impl ResponseInstruction { - fn into_instructions(self) -> MessageSendInstructions { + /// Converts this [`ResponseInstruction`] into a [`MessageSendInstructions`] so that it can be + /// used to send the response via a normal message sending method. + pub fn into_instructions(self) -> MessageSendInstructions { MessageSendInstructions::ForReply { instructions: self } } }