diff --git a/Cargo.lock b/Cargo.lock index bbf1674..b576edd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5514,6 +5514,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "tokio", "utoipa", ] diff --git a/app/app.rs b/app/app.rs index bc9c19c..bb663d0 100644 --- a/app/app.rs +++ b/app/app.rs @@ -11,8 +11,10 @@ use plain_bitnames::{ miner::{self, Miner}, node::{self, Node}, types::{ - self, Address, AmountOverflowError, BitcoinOutputContent, Body, - FilledOutput, GetValue, InPoint, OutPoint, Transaction, + self, Address, AmountOverflowError, BitNameDataUpdates, + BitNameResolution, BitcoinOutputContent, BlockHash, Body, FilledOutput, + GetValue, InPoint, OutPoint, PaymailEntry, PaymailRecipient, + Transaction, Txid, hashes::BitName, proto::mainchain::{ self, @@ -46,6 +48,8 @@ pub enum Error { Node(#[source] Box), #[error("No CUSF mainchain wallet client")] NoCusfMainchainWalletClient, + #[error("No current owner output exists for BitName {bitname}")] + NoBitNameOwnerOutput { bitname: BitName }, #[error("Failed to request mainchain ancestor info for {block_hash}")] RequestMainchainAncestorInfos { block_hash: bitcoin::BlockHash }, #[error("Unable to verify existence of CUSF mainchain service(s) at {url}")] @@ -63,6 +67,15 @@ impl From for Error { } } +type ChainPosition = (u32, u32); + +fn ownership_contains( + positions: &Range, + position: ChainPosition, +) -> bool { + positions.contains(&position) +} + fn update_wallet(node: &Node, wallet: &Wallet) -> Result<(), Error> { tracing::trace!("starting wallet update"); let addresses = wallet.get_addresses()?; @@ -235,6 +248,8 @@ impl App { tracing::debug!("Initializing node..."); let node = runtime.block_on(Node::new( config.net_addr, + config.tor_proxy_mode, + config.tor_proxy_peer, &config.datadir, config.network, cusf_mainchain, @@ -306,144 +321,227 @@ impl App { self.runtime.block_on(self.get_new_main_address()) } - /** Get all paymail. - * If `inbox_whitelist` is `Some`, - * only the specified bitnames will be used as inboxes. */ - pub fn get_paymail( + /// Resolve a BitName to its current owner output, address, and mutable data. + pub fn resolve_bitname( + &self, + bitname: BitName, + ) -> Result { + let data = self.node.get_current_bitname_data(&bitname)?; + let (outpoint, output) = self + .node + .get_all_utxos()? + .into_iter() + .find(|(_, output)| output.bitname() == Some(&bitname)) + .ok_or(Error::NoBitNameOwnerOutput { bitname })?; + Ok(BitNameResolution { + bitname, + outpoint, + address: output.address, + data, + }) + } + + /// Update mutable data for an owned BitName while retaining ownership. + pub fn update_bitname( + &self, + bitname: BitName, + updates: BitNameDataUpdates, + fee: bitcoin::Amount, + ) -> Result { + let transaction = + self.wallet.create_bitname_update(bitname, updates, fee)?; + let txid = transaction.txid(); + self.sign_and_send(transaction)?; + Ok(txid) + } + + fn confirmed_tx_location( + &self, + txid: Txid, + tip: BlockHash, + ) -> Result, Error> { + for (block_hash, tx_index) in self.node.get_tx_inclusions(txid)? { + if self.node.is_descendant(block_hash, tip)? { + let height = self.node.get_height(block_hash)?; + return Ok(Some((block_hash, height, tx_index))); + } + } + Ok(None) + } + + fn confirmed_outpoint_location( + &self, + outpoint: OutPoint, + tip: BlockHash, + ) -> Result, Error> { + match outpoint { + OutPoint::Regular { txid, vout: _ } => { + self.confirmed_tx_location(txid, tip) + } + OutPoint::Coinbase { .. } | OutPoint::Deposit(_) => Ok(None), + } + } + + /** Get JSON-safe paymail entries. + * If `inbox_whitelist` is `Some`, only the specified BitNames will be + * used as inboxes. Both unspent and spent wallet outputs are considered so + * spending a received payment does not erase mailbox history. */ + pub fn get_paymail_entries( &self, inbox_whitelist: Option<&HashSet>, - ) -> Result, Error> { - let mut utxos = self.wallet.get_utxos()?; + ) -> Result, Error> { + #[derive(Clone)] + struct Ownership { + bitname: BitName, + positions: Range, + } + + let Some(tip) = self.node.try_get_tip()? else { + return Ok(Vec::new()); + }; + + let mut mailbox_outputs = self.wallet.get_utxos()?; + for (outpoint, spent_output) in self.wallet.get_stxos()? { + mailbox_outputs + .entry(outpoint) + .or_insert(spent_output.output); + } + let mut bitname_utxos = self.wallet.get_bitnames()?; let mut bitname_stxos = self.wallet.get_spent_bitnames()?; if let Some(inbox_whitelist) = inbox_whitelist { bitname_utxos.retain(|_, output| { - let Some(bitname) = output.bitname() else { - return false; - }; - inbox_whitelist.contains(bitname) + output + .bitname() + .is_some_and(|bitname| inbox_whitelist.contains(bitname)) }); - bitname_stxos.retain(|_, output| { - let Some(bitname) = output.output.bitname() else { - return false; - }; - inbox_whitelist.contains(bitname) - }) - }; - let Some(tip) = self.node.try_get_tip()? else { - return Ok(HashMap::new()); - }; - let outpoints_to_block_heights: HashMap<_, _> = utxos - .iter() - .map(|(&outpoint, _)| outpoint) - .chain(bitname_stxos.iter().map(|(&outpoint, _)| outpoint)) - .filter_map(|outpoint| match outpoint { - OutPoint::Regular { txid, vout: _ } => Some((outpoint, txid)), - _ => None, - }) - .map(|(outpoint, txid)| { - let inclusions = self.node.get_tx_inclusions(txid)?; - let Some(block_hash) = - inclusions.into_keys().try_find(|block_hash| { - self.node.is_descendant(*block_hash, tip) - })? - else { - return Ok((outpoint, None)); - }; - let height = self.node.get_height(block_hash)?; - Ok((outpoint, Some(height))) - }) - .collect::>()?; - let inpoints_to_block_heights: HashMap<_, _> = - bitname_stxos.values() - .map(|spent_output| { - let txid = match spent_output.inpoint { - InPoint::Regular { txid, vin:_ } => txid, - _ => panic!( - "Impossible: bitname inpoint can only refer to regular tx" - ) - }; - let inclusions = self.node.get_tx_inclusions(txid)?; - let Some(block_hash) = inclusions.into_keys().try_find(|block_hash| { - self.node.is_descendant(*block_hash, tip) - })? else { - return Ok((spent_output.inpoint, None)); - }; - let height = self.node.get_height(block_hash)?; - Ok((spent_output.inpoint, Some(height))) - }).collect::>()?; - /* associate to each address, a set of pairs of bitname data and - ownership period for the bitname. */ - let mut addrs_to_bitnames_ownership: HashMap<_, HashSet<_>> = + bitname_stxos.retain(|_, spent_output| { + spent_output + .output + .bitname() + .is_some_and(|bitname| inbox_whitelist.contains(bitname)) + }); + } + + let mut ownership_by_address: HashMap> = HashMap::new(); - // populate with owned bitnames for (outpoint, output) in bitname_utxos { - let Some(bitname) = output.bitname() else { + let Some(&bitname) = output.bitname() else { continue; }; - let bitname_data = self.node.get_current_bitname_data(bitname)?; - let Some(height) = outpoints_to_block_heights[&outpoint] else { + let Some((_, acquired_height, acquired_tx_index)) = + self.confirmed_outpoint_location(outpoint, tip)? + else { continue; }; - let owned = Range { - start: height, - end: u32::MAX, - }; - addrs_to_bitnames_ownership + ownership_by_address .entry(output.address) .or_default() - .insert((bitname_data, owned)); + .push(Ownership { + bitname, + positions: (acquired_height, acquired_tx_index) + ..(u32::MAX, u32::MAX), + }); } - // populate with spent bitnames - for (outpoint, output) in bitname_stxos { - let Some(bitname) = output.output.bitname() else { + for (outpoint, spent_output) in bitname_stxos { + let Some(&bitname) = spent_output.output.bitname() else { continue; }; - let Some(acquired_height) = outpoints_to_block_heights[&outpoint] + let Some((_, acquired_height, acquired_tx_index)) = + self.confirmed_outpoint_location(outpoint, tip)? else { continue; }; - let spent_height = inpoints_to_block_heights[&output.inpoint]; - let bitname_data = self - .node - .get_bitname_data_at_block_height(bitname, acquired_height)?; - let owned = Range { - start: acquired_height, - end: spent_height.unwrap_or(u32::MAX), + let spent_txid = match spent_output.inpoint { + InPoint::Regular { txid, vin: _ } => txid, + InPoint::Withdrawal { .. } => continue, }; - addrs_to_bitnames_ownership - .entry(output.output.address) + let spent_position = self + .confirmed_tx_location(spent_txid, tip)? + .map_or((u32::MAX, u32::MAX), |(_, height, tx_index)| { + (height, tx_index) + }); + ownership_by_address + .entry(spent_output.output.address) .or_default() - .insert((bitname_data, owned)); + .push(Ownership { + bitname, + positions: (acquired_height, acquired_tx_index) + ..spent_position, + }); } - // retain if memo exists, and output value >= paymail fee - utxos.retain(|outpoint, output| { + + let mut entries = Vec::new(); + for (outpoint, output) in mailbox_outputs { if output.memo.is_empty() { - return false; + continue; } - let Some(bitname_data_ownership) = - addrs_to_bitnames_ownership.get(&output.address) + let Some((block_hash, block_height, tx_index)) = + self.confirmed_outpoint_location(outpoint, tip)? else { - return false; + continue; }; - let Some(height) = outpoints_to_block_heights[outpoint] else { - return false; + let Some(ownerships) = ownership_by_address.get(&output.address) + else { + continue; }; - let min_fee = bitname_data_ownership + let position = (block_height, tx_index); + let mut recipients: Vec<_> = ownerships .iter() - .filter_map(|(bitname_data, ownership)| { - if !ownership.contains(&height) { - return None; - }; - bitname_data.mutable_data.paymail_fee_sats + .filter(|ownership| { + ownership_contains(&ownership.positions, position) }) - .min(); - let Some(min_fee) = min_fee else { - return false; - }; - output.get_value().to_sat() >= min_fee + .map(|ownership| -> Result { + let data = self.node.get_bitname_data_at_block_position( + &ownership.bitname, + block_hash, + tx_index, + )?; + Ok(PaymailRecipient { + bitname: ownership.bitname, + required_fee_sats: data.mutable_data.paymail_fee_sats, + data, + }) + }) + .collect::>()?; + recipients.sort_by_key(|recipient| recipient.bitname); + recipients.dedup_by_key(|recipient| recipient.bitname); + if recipients.is_empty() { + continue; + } + entries.push(PaymailEntry { + outpoint, + value_sats: output.get_value().to_sat(), + output, + block_hash, + block_height, + tx_index, + recipients, + }); + } + entries.sort_by_key(|entry| { + (entry.block_height, entry.tx_index, entry.outpoint) }); - Ok(utxos) + Ok(entries) + } + + /** Legacy paymail response. + * + * New JSON clients should use `get_paymail_entries`, whose vector response + * does not use `OutPoint` as a JSON object key. */ + pub fn get_paymail( + &self, + inbox_whitelist: Option<&HashSet>, + ) -> Result, Error> { + let unspent_outpoints: HashSet<_> = + self.wallet.get_utxos()?.into_keys().collect(); + Ok(self + .get_paymail_entries(inbox_whitelist)? + .into_iter() + .filter(|entry| unspent_outpoints.contains(&entry.outpoint)) + .filter(PaymailEntry::meets_advertised_fee) + .map(|entry| (entry.outpoint, entry.output)) + .collect()) } const EMPTY_BLOCK_BMM_BRIBE: bitcoin::Amount = @@ -646,3 +744,18 @@ impl Drop for App { self.task.abort() } } + +#[cfg(test)] +mod tests { + use super::ownership_contains; + + #[test] + fn ownership_uses_transaction_order_within_a_block() { + let positions = (12, 1)..(12, 4); + + assert!(!ownership_contains(&positions, (12, 0))); + assert!(ownership_contains(&positions, (12, 1))); + assert!(ownership_contains(&positions, (12, 3))); + assert!(!ownership_contains(&positions, (12, 4))); + } +} diff --git a/app/cli.rs b/app/cli.rs index 49b4e1b..9f86269 100644 --- a/app/cli.rs +++ b/app/cli.rs @@ -1,5 +1,5 @@ use std::{ - net::{IpAddr, Ipv4Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, ops::Deref, path::PathBuf, sync::LazyLock, @@ -36,6 +36,14 @@ const DEFAULT_NET_ADDR: SocketAddr = const DEFAULT_RPC_ADDR: SocketAddr = ipv4_socket_addr([127, 0, 0, 1], 6000 + THIS_SIDECHAIN as u16); +fn loopback_addr(addr: SocketAddr) -> SocketAddr { + let ip = match addr.ip() { + IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::LOCALHOST), + }; + SocketAddr::new(ip, addr.port()) +} + #[cfg(feature = "zmq")] const DEFAULT_ZMQ_ADDR: SocketAddr = ipv4_socket_addr([127, 0, 0, 1], 28000 + THIS_SIDECHAIN as u16); @@ -144,6 +152,13 @@ pub(super) struct Cli { /// Socket address to host the RPC server #[arg(default_value_t = DEFAULT_RPC_ADDR, long, short)] rpc_addr: SocketAddr, + /// Allow P2P only through loopback UDP tunnel sidecars. + /// Forces the P2P listener to loopback and rejects direct peers. + #[arg(long)] + tor_proxy_mode: bool, + /// The single loopback tunnel peer trusted in Tor proxy mode. + #[arg(long, requires = "tor_proxy_mode")] + tor_proxy_peer: Option, /// ZMQ pub/sub address #[cfg(feature = "zmq")] #[arg(default_value_t = DEFAULT_ZMQ_ADDR, long, short)] @@ -182,6 +197,11 @@ impl Cli { } else { saturating_pred_level(self.log_level) }; + let net_addr = if self.tor_proxy_mode { + loopback_addr(self.net_addr) + } else { + self.net_addr + }; Ok(Config { datadir: self.datadir.0, file_log_level: self.file_log_level, @@ -190,9 +210,11 @@ impl Cli { log_level, mainchain_grpc_url, mnemonic_seed_phrase_path: self.mnemonic_seed_phrase_path, - net_addr: self.net_addr, + net_addr, network: self.network, rpc_addr: self.rpc_addr, + tor_proxy_mode: self.tor_proxy_mode, + tor_proxy_peer: self.tor_proxy_peer, #[cfg(feature = "zmq")] zmq_addr: self.zmq_addr, }) @@ -212,6 +234,60 @@ pub struct Config { pub net_addr: SocketAddr, pub network: Network, pub rpc_addr: SocketAddr, + pub tor_proxy_mode: bool, + pub tor_proxy_peer: Option, #[cfg(feature = "zmq")] pub zmq_addr: SocketAddr, } + +#[cfg(test)] +mod tests { + use super::*; + + fn config_from(args: &[&str]) -> Config { + Cli::try_parse_from(args).unwrap().get_config().unwrap() + } + + #[test] + fn tor_proxy_mode_propagates_and_forces_ipv4_loopback() { + let config = config_from(&[ + "plain-bitnames", + "--datadir", + "test-data", + "--net-addr", + "0.0.0.0:4002", + "--tor-proxy-mode", + ]); + + assert!(config.tor_proxy_mode); + assert_eq!(config.net_addr, "127.0.0.1:4002".parse().unwrap()); + } + + #[test] + fn tor_proxy_mode_preserves_ipv6_and_port() { + let config = config_from(&[ + "plain-bitnames", + "--datadir", + "test-data", + "--net-addr", + "[2001:db8::1]:4102", + "--tor-proxy-mode", + ]); + + assert_eq!(config.net_addr, "[::1]:4102".parse().unwrap()); + } + + #[test] + fn direct_p2p_mode_remains_the_default() { + let config = config_from(&[ + "plain-bitnames", + "--datadir", + "test-data", + "--net-addr", + "192.0.2.1:4002", + ]); + + assert!(!config.tor_proxy_mode); + assert_eq!(config.net_addr, "192.0.2.1:4002".parse().unwrap()); + } +} diff --git a/app/main.rs b/app/main.rs index 284bda9..7ddacb6 100644 --- a/app/main.rs +++ b/app/main.rs @@ -1,4 +1,3 @@ -#![feature(try_find)] use std::{path::Path, sync::Arc}; use clap::Parser as _; diff --git a/app/rpc_server.rs b/app/rpc_server.rs index 2b0435b..a059ec0 100644 --- a/app/rpc_server.rs +++ b/app/rpc_server.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, collections::HashMap, net::SocketAddr}; +use std::{borrow::Cow, collections::HashMap, net::SocketAddr, sync::Mutex}; use bitcoin::Amount; use jsonrpsee::{ @@ -9,12 +9,12 @@ use jsonrpsee::{ use plain_bitnames::{ authorization::{self, Dst, Signature}, - net::Peer, + net::{Peer, TorProxyStatus}, types::{ - Address, Authorization, BitName, BitNameData, Block, BlockHash, - EncryptionPubKey, FilledOutput, MutableBitNameData, OutPoint, - PointedOutput, SpentOutput, Transaction, Txid, VerifyingKey, - WithdrawalBundle, + Address, Authorization, BitName, BitNameData, BitNameDataUpdates, + BitNameResolution, Block, BlockHash, EncryptionPubKey, FilledOutput, + MutableBitNameData, OutPoint, PaymailEntry, PointedOutput, SpentOutput, + Transaction, Txid, VerifyingKey, WithdrawalBundle, keys::{Ecies, XEncryptionSecretKey, XVerifyingKey}, }, wallet::Balance, @@ -44,6 +44,7 @@ where pub struct RpcServerImpl { app: App, + transfer_lock: Mutex<()>, } #[async_trait] @@ -62,10 +63,29 @@ impl RpcServer for RpcServerImpl { .map_err(custom_err) } + async fn bitname_data_at_position( + &self, + bitname: BitName, + block_hash: BlockHash, + tx_index: u32, + ) -> RpcResult { + self.app + .node + .get_bitname_data_at_block_position(&bitname, block_hash, tx_index) + .map_err(custom_err) + } + async fn bitnames(&self) -> RpcResult> { self.app.node.bitnames().map_err(custom_err) } + async fn resolve_bitname( + &self, + bitname: BitName, + ) -> RpcResult { + self.app.resolve_bitname(bitname).map_err(custom_err) + } + async fn connect_peer(&self, addr: SocketAddr) -> RpcResult<()> { self.app.node.connect_peer(addr).map_err(custom_err) } @@ -194,6 +214,10 @@ impl RpcServer for RpcServerImpl { self.app.get_paymail(None).map_err(custom_err) } + async fn get_paymail_entries(&self) -> RpcResult> { + self.app.get_paymail_entries(None).map_err(custom_err) + } + async fn get_transaction( &self, txid: Txid, @@ -297,6 +321,10 @@ impl RpcServer for RpcServerImpl { Ok(peers) } + async fn tor_proxy_status(&self) -> RpcResult { + Ok(self.app.node.tor_proxy_status()) + } + async fn list_stxos(&self) -> RpcResult>> { let stxos = self.app.node.get_all_stxos().map_err(custom_err)?; let res = stxos @@ -385,6 +413,17 @@ impl RpcServer for RpcServerImpl { Ok(txid) } + async fn update_bitname( + &self, + bitname: BitName, + updates: BitNameDataUpdates, + fee_sats: u64, + ) -> RpcResult { + self.app + .update_bitname(bitname, updates, Amount::from_sat(fee_sats)) + .map_err(custom_err) + } + async fn set_seed_from_mnemonic(&self, mnemonic: String) -> RpcResult<()> { self.app .wallet @@ -430,7 +469,12 @@ impl RpcServer for RpcServerImpl { value_sats: u64, fee_sats: u64, memo: Option, + idempotency_key: Option, ) -> RpcResult { + let _guard = self + .transfer_lock + .lock() + .map_err(|_| custom_err_msg("transfer lock poisoned"))?; let memo = match memo { None => None, Some(memo) => { @@ -438,18 +482,26 @@ impl RpcServer for RpcServerImpl { Some(hex) } }; - let tx = self - .app - .wallet - .create_transfer( - dest, - Amount::from_sat(value_sats), - Amount::from_sat(fee_sats), - memo, - ) - .map_err(custom_err)?; + let value = Amount::from_sat(value_sats); + let fee = Amount::from_sat(fee_sats); + let tx = if let Some(key) = idempotency_key { + self.app + .wallet + .create_idempotent_transfer(&key, dest, value, fee, memo) + } else { + self.app.wallet.create_transfer(dest, value, fee, memo) + } + .map_err(custom_err)?; let txid = tx.txid(); - self.app.sign_and_send(tx).map_err(custom_err)?; + if self + .app + .node + .try_get_transaction(txid) + .map_err(custom_err)? + .is_none() + { + self.app.sign_and_send(tx).map_err(custom_err)?; + } Ok(txid) } @@ -571,7 +623,13 @@ pub async fn run_server( .await?; let addr = server.local_addr()?; - let handle = server.start(RpcServerImpl { app }.into_rpc()); + let handle = server.start( + RpcServerImpl { + app, + transfer_lock: Mutex::new(()), + } + .into_rpc(), + ); // In this example we don't care about doing shutdown so let's it run forever. // You may use the `ServerHandle` to shut it down or manage it yourself. diff --git a/cli/lib.rs b/cli/lib.rs index c20efea..3e9a6b2 100644 --- a/cli/lib.rs +++ b/cli/lib.rs @@ -450,7 +450,7 @@ where fee_sats, } => { let txid = rpc_client - .transfer(dest, value_sats, fee_sats, None) + .transfer(dest, value_sats, fee_sats, None, None) .await?; format!("{txid}") } diff --git a/lib/net/error.rs b/lib/net/error.rs index 4f55a32..28b8017 100644 --- a/lib/net/error.rs +++ b/lib/net/error.rs @@ -101,6 +101,12 @@ pub enum Error { Io(#[from] std::io::Error), #[error("peer connection not found for {0}")] MissingPeerConnection(SocketAddr), + /// Direct peers are forbidden when P2P is restricted to loopback tunnel + /// sidecars. + #[error("non-loopback peer '{0}' is forbidden in Tor proxy mode")] + NonLoopbackPeerInTorProxyMode(SocketAddr), + #[error("peer '{0}' is not the trusted Tor tunnel peer")] + UntrustedTorProxyPeer(SocketAddr), /// Unspecified peer IP addresses cannot be connected to. /// `0.0.0.0` is one example of an "unspecified" IP. #[error("unspecified peer ip address (cannot connect to '{0}')")] diff --git a/lib/net/mod.rs b/lib/net/mod.rs index 22ce374..a8bac25 100644 --- a/lib/net/mod.rs +++ b/lib/net/mod.rs @@ -1,6 +1,6 @@ use std::{ collections::{HashMap, HashSet, hash_map}, - net::SocketAddr, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, sync::Arc, }; @@ -9,9 +9,11 @@ use futures::{StreamExt, channel::mpsc}; use heed::types::{SerdeBincode, Unit}; use parking_lot::RwLock; use quinn::{ClientConfig, Endpoint, ServerConfig}; +use serde::{Deserialize, Serialize}; use sneed::{DatabaseUnique, DbError, EnvError, RwTxn, RwTxnError, UnitKey}; use tokio_stream::StreamNotifyClose; use tracing::instrument; +use utoipa::ToSchema; use crate::{ archive::Archive, @@ -140,6 +142,22 @@ pub fn make_server_endpoint( pub type PeerInfoRx = mpsc::UnboundedReceiver<(SocketAddr, Option)>; +#[derive( + Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, +)] +pub struct TorProxyStatus { + pub tor_proxy_mode: bool, + /// Connected loopback peers, which are the only peers permitted in Tor + /// proxy mode. + pub connected_tunnel_peers: u32, +} + +impl TorProxyStatus { + pub fn allows_transaction_submission(self) -> bool { + !self.tor_proxy_mode || self.connected_tunnel_peers > 0 + } +} + const SIGNET_SEED_NODE_ADDRS: &[SocketAddr] = { const SIGNET_MINING_SERVER: SocketAddr = SocketAddr::new( std::net::IpAddr::V4(std::net::Ipv4Addr::new(172, 105, 148, 135)), @@ -162,7 +180,13 @@ const FORKNET_SEED_NODE_ADDRS: &[SocketAddr] = { &[BIP300_XYZ] }; -const fn seed_node_addrs(network: Network) -> &'static [SocketAddr] { +const fn seed_node_addrs( + network: Network, + tor_proxy_mode: bool, +) -> &'static [SocketAddr] { + if tor_proxy_mode { + return &[]; + } match network { Network::Signet => SIGNET_SEED_NODE_ADDRS, Network::Regtest => &[], @@ -170,6 +194,92 @@ const fn seed_node_addrs(network: Network) -> &'static [SocketAddr] { } } +fn loopback_addr(addr: SocketAddr) -> SocketAddr { + let ip = match addr.ip() { + IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::LOCALHOST), + }; + SocketAddr::new(ip, addr.port()) +} + +fn peer_address_allowed( + tor_proxy_mode: bool, + tor_proxy_peer: Option, + addr: SocketAddr, +) -> bool { + !tor_proxy_mode + || tor_proxy_peer + .map_or_else(|| addr.ip().is_loopback(), |trusted| addr == trusted) +} + +fn is_connected_tunnel_peer( + tor_proxy_mode: bool, + tor_proxy_peer: Option, + addr: SocketAddr, + status: PeerConnectionStatus, +) -> bool { + tor_proxy_mode + && peer_address_allowed(true, tor_proxy_peer, addr) + && status == PeerConnectionStatus::Connected +} + +fn validate_peer_address( + tor_proxy_mode: bool, + tor_proxy_peer: Option, + addr: SocketAddr, +) -> Result<(), Error> { + if addr.ip().is_unspecified() { + return Err(Error::UnspecfiedPeerIP(addr.ip())); + } + if !peer_address_allowed(tor_proxy_mode, tor_proxy_peer, addr) { + return Err(if tor_proxy_peer.is_some() { + Error::UntrustedTorProxyPeer(addr) + } else { + Error::NonLoopbackPeerInTorProxyMode(addr) + }); + } + Ok(()) +} + +fn queue_transaction_to_peers( + active_peers: &HashMap, + tor_proxy_mode: bool, + tor_proxy_peer: Option, + exclude: &HashSet, + tx: &AuthorizedTransaction, +) -> usize { + let mut queued_peers = 0; + for (addr, peer_connection_handle) in active_peers { + if exclude.contains(addr) + || !peer_address_allowed(tor_proxy_mode, tor_proxy_peer, *addr) + { + continue; + } + match peer_connection_handle.connection_status() { + PeerConnectionStatus::Connecting => { + tracing::trace!(%addr, "skipping peer at {addr} because it is not fully connected"); + continue; + } + PeerConnectionStatus::Connected => {} + } + let request: PeerRequest = peer::message::PushTransactionRequest { + transaction: tx.clone(), + } + .into(); + if peer_connection_handle + .internal_message_tx + .unbounded_send(request.into()) + .is_ok() + { + queued_peers += 1; + } else { + let txid = tx.transaction.txid(); + tracing::warn!("Failed to push tx {txid} to peer at {addr}") + } + } + queued_peers +} + // Keep track of peer state // Exchange metadata // Bulk download @@ -191,6 +301,8 @@ pub struct Net { peer_info_tx: mpsc::UnboundedSender<(SocketAddr, Option)>, known_peers: DatabaseUnique, Unit>, + tor_proxy_mode: bool, + tor_proxy_peer: Option, _version: DatabaseUnique>, } @@ -252,23 +364,40 @@ impl Net { .collect() } + pub fn tor_proxy_status(&self) -> TorProxyStatus { + let connected_tunnel_peers = self + .active_peers + .read() + .iter() + .filter(|(addr, connection)| { + is_connected_tunnel_peer( + self.tor_proxy_mode, + self.tor_proxy_peer, + **addr, + connection.connection_status(), + ) + }) + .count() + .try_into() + .unwrap_or(u32::MAX); + TorProxyStatus { + tor_proxy_mode: self.tor_proxy_mode, + connected_tunnel_peers, + } + } + #[instrument(skip_all, fields(addr), err(Debug))] pub fn connect_peer( &self, env: sneed::Env, addr: SocketAddr, ) -> Result<(), Error> { + // Reconnects and manual RPC connections both flow through this check. + validate_peer_address(self.tor_proxy_mode, self.tor_proxy_peer, addr)?; if self.active_peers.read().contains_key(&addr) { tracing::error!("already connected"); return Err(error::AlreadyConnected(addr).into()); } - // This check happens within Quinn with a - // generic "invalid remote address". We run the - // same check, and provide a friendlier error - // message. - if addr.ip().is_unspecified() { - return Err(Error::UnspecfiedPeerIP(addr.ip())); - } let connecting = self.server.connect(addr, "localhost")?; let mut rwtxn = env.write_txn().map_err(EnvError::from)?; self.known_peers @@ -317,7 +446,14 @@ impl Net { network: Network, state: State, bind_addr: SocketAddr, + tor_proxy_mode: bool, + tor_proxy_peer: Option, ) -> Result<(Self, PeerInfoRx), Error> { + let bind_addr = if tor_proxy_mode { + loopback_addr(bind_addr) + } else { + bind_addr + }; let (server, _) = make_server_endpoint(bind_addr)?; let active_peers = Arc::new(RwLock::new(HashMap::new())); let mut rwtxn = env.write_txn()?; @@ -327,7 +463,9 @@ impl Net { None => { let known_peers = DatabaseUnique::create(env, &mut rwtxn, "known_peers")?; - for seed_node_addr in seed_node_addrs(network) { + for seed_node_addr in + seed_node_addrs(network, tor_proxy_mode) + { known_peers.put(&mut rwtxn, seed_node_addr, &())?; } known_peers @@ -347,6 +485,8 @@ impl Net { active_peers, peer_info_tx, known_peers, + tor_proxy_mode, + tor_proxy_peer, _version: version, }; #[allow(clippy::let_and_return)] @@ -356,6 +496,20 @@ impl Net { .known_peers .iter(&rotxn) .map_err(DbError::from)? + .filter(|(peer_addr, _)| { + let allowed = peer_address_allowed( + tor_proxy_mode, + tor_proxy_peer, + *peer_addr, + ); + if !allowed { + tracing::info!( + %peer_addr, + "ignoring persisted direct peer in Tor proxy mode" + ); + } + Ok(allowed) + }) .collect() .map_err(DbError::from)?; known_peers @@ -420,6 +574,18 @@ impl Net { }; let addr = connection.addr(); tracing::trace!(%addr, "accepted incoming connection"); + // Incoming sidecar streams use ephemeral loopback source ports; only + // outbound submission/reconnect trust is pinned to tor_proxy_peer. + if !peer_address_allowed(self.tor_proxy_mode, None, addr) { + tracing::warn!( + %addr, + "refusing non-loopback connection in Tor proxy mode" + ); + connection + .inner + .close(quinn::VarInt::from_u32(2), b"direct peer forbidden"); + return Ok(None); + } if self.active_peers.read().contains_key(&addr) { tracing::info!( %addr, "already peered, refusing duplicate", @@ -461,6 +627,10 @@ impl Net { Ok(Some(addr)) } + pub(crate) fn peer_address_allowed(&self, addr: SocketAddr) -> bool { + peer_address_allowed(self.tor_proxy_mode, self.tor_proxy_peer, addr) + } + /// Attempt to push an internal message to the specified peer /// Returns `true` if successful pub fn push_internal_message( @@ -489,33 +659,219 @@ impl Net { } /// Push a tx to all active peers, except those in the provided set + #[must_use] pub fn push_tx( &self, exclude: HashSet, tx: AuthorizedTransaction, - ) { - self.active_peers - .read() - .iter() - .filter(|(addr, _)| !exclude.contains(addr)) - .for_each(|(addr, peer_connection_handle)| { - match peer_connection_handle.connection_status() { - PeerConnectionStatus::Connecting => { - tracing::trace!(%addr, "skipping peer at {addr} because it is not fully connected"); - return; - } - PeerConnectionStatus::Connected => {} - } - let request: PeerRequest = peer::message::PushTransactionRequest { - transaction: tx.clone(), - }.into(); - if let Err(_send_err) = peer_connection_handle - .internal_message_tx - .unbounded_send(request.into()) - { - let txid = tx.transaction.txid(); - tracing::warn!("Failed to push tx {txid} to peer at {addr}") - } + ) -> usize { + let active_peers = self.active_peers.read(); + queue_transaction_to_peers( + &active_peers, + self.tor_proxy_mode, + self.tor_proxy_peer, + &exclude, + &tx, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tor_proxy_mode_forces_bind_address_to_loopback() { + assert_eq!( + loopback_addr("0.0.0.0:4002".parse().unwrap()), + "127.0.0.1:4002".parse().unwrap() + ); + assert_eq!( + loopback_addr("[2001:db8::1]:4102".parse().unwrap()), + "[::1]:4102".parse().unwrap() + ); + } + + #[test] + fn tor_proxy_mode_disables_seed_peers() { + assert!(seed_node_addrs(Network::Signet, true).is_empty()); + assert!(!seed_node_addrs(Network::Signet, false).is_empty()); + } + + #[test] + fn tor_proxy_mode_rejects_direct_peer_addresses() { + let addr = "192.0.2.1:4002".parse().unwrap(); + assert!(matches!( + validate_peer_address(true, None, addr), + Err(Error::NonLoopbackPeerInTorProxyMode(rejected)) + if rejected == addr + )); + } + + #[test] + fn tor_proxy_mode_filters_persisted_direct_peers() { + let persisted = [ + "127.0.0.1:4002".parse().unwrap(), + "192.0.2.1:4002".parse().unwrap(), + "[::1]:4002".parse().unwrap(), + "[2001:db8::1]:4002".parse().unwrap(), + ]; + let filtered: Vec<_> = persisted + .into_iter() + .filter(|addr| peer_address_allowed(true, None, *addr)) + .collect(); + + assert_eq!( + filtered, + [ + "127.0.0.1:4002".parse().unwrap(), + "[::1]:4002".parse().unwrap(), + ] + ); + } + + #[test] + fn tor_proxy_mode_allows_loopback_udp_tunnel_peers() { + for addr in ["127.0.0.1:4002", "[::1]:4002"] { + assert!( + validate_peer_address(true, None, addr.parse().unwrap()) + .is_ok() + ); + } + } + + #[test] + fn tor_proxy_status_requires_a_connected_tunnel_for_submission() { + let ready = TorProxyStatus { + tor_proxy_mode: true, + connected_tunnel_peers: 1, + }; + assert!( + TorProxyStatus { + tor_proxy_mode: false, + connected_tunnel_peers: 0, + } + .allows_transaction_submission() + ); + assert!( + !TorProxyStatus { + tor_proxy_mode: true, + connected_tunnel_peers: 0, + } + .allows_transaction_submission() + ); + assert!(ready.allows_transaction_submission()); + assert_eq!( + serde_json::to_value(ready).unwrap(), + serde_json::json!({ + "tor_proxy_mode": true, + "connected_tunnel_peers": 1, }) + ); + } + + #[test] + fn only_connected_loopback_peers_count_as_tunnels() { + let loopback = "127.0.0.1:4002".parse().unwrap(); + let direct = "192.0.2.1:4002".parse().unwrap(); + + assert!(is_connected_tunnel_peer( + true, + None, + loopback, + PeerConnectionStatus::Connected + )); + assert!(!is_connected_tunnel_peer( + true, + None, + loopback, + PeerConnectionStatus::Connecting + )); + assert!(!is_connected_tunnel_peer( + true, + None, + direct, + PeerConnectionStatus::Connected + )); + assert!(!is_connected_tunnel_peer( + false, + None, + loopback, + PeerConnectionStatus::Connected + )); + } + + #[test] + fn queued_peer_count_only_reports_successful_allowed_delivery() { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let transaction = AuthorizedTransaction { + transaction: crate::types::Transaction::default(), + authorizations: Vec::new(), + }; + let loopback = "127.0.0.1:4002".parse().unwrap(); + + let (connected, _connected_rx) = + peer::test_connection_handle(PeerConnectionStatus::Connected); + let connected_peers = HashMap::from([(loopback, connected)]); + assert_eq!( + queue_transaction_to_peers( + &connected_peers, + true, + None, + &HashSet::new(), + &transaction, + ), + 1 + ); + + let (closed, closed_rx) = + peer::test_connection_handle(PeerConnectionStatus::Connected); + drop(closed_rx); + let closed_peers = HashMap::from([(loopback, closed)]); + assert_eq!( + queue_transaction_to_peers( + &closed_peers, + true, + None, + &HashSet::new(), + &transaction, + ), + 0 + ); + + let direct = "192.0.2.1:4002".parse().unwrap(); + let (direct_peer, _direct_rx) = + peer::test_connection_handle(PeerConnectionStatus::Connected); + assert_eq!( + queue_transaction_to_peers( + &HashMap::from([(direct, direct_peer)]), + false, + None, + &HashSet::new(), + &transaction, + ), + 1 + ); + }); + } + + #[test] + fn direct_mode_still_allows_non_loopback_peers() { + let addr = "192.0.2.1:4002".parse().unwrap(); + assert!(validate_peer_address(false, None, addr).is_ok()); + } + + #[test] + fn tor_proxy_mode_allows_only_the_configured_tunnel() { + let trusted = "127.0.0.1:4100".parse().unwrap(); + assert!(validate_peer_address(true, Some(trusted), trusted).is_ok()); + assert!(matches!( + validate_peer_address( + true, + Some(trusted), + "127.0.0.1:4200".parse().unwrap() + ), + Err(Error::UntrustedTorProxyPeer(_)) + )); } } diff --git a/lib/net/peer/mod.rs b/lib/net/peer/mod.rs index 8ca4408..2b312a7 100644 --- a/lib/net/peer/mod.rs +++ b/lib/net/peer/mod.rs @@ -442,6 +442,21 @@ impl Drop for ConnectionHandle { } } +#[cfg(test)] +pub(in crate::net) fn test_connection_handle( + status: PeerConnectionStatus, +) -> (ConnectionHandle, mpsc::UnboundedReceiver) { + let (internal_message_tx, internal_message_rx) = mpsc::unbounded(); + let task = spawn(std::future::pending()); + let connection_handle = ConnectionHandle { + task, + received_msg_successfully: Arc::new(AtomicBool::new(false)), + status_repr: Arc::new(AtomicBool::new(status.as_repr())), + internal_message_tx, + }; + (connection_handle, internal_message_rx) +} + /// Handle an existing connection pub fn handle( ctxt: ConnectionContext, diff --git a/lib/node/mod.rs b/lib/node/mod.rs index b052172..60bd7f4 100644 --- a/lib/node/mod.rs +++ b/lib/node/mod.rs @@ -17,7 +17,7 @@ use tonic::transport::Channel; use crate::{ archive::{self, Archive}, mempool::{self, MemPool}, - net::{self, Net, Peer}, + net::{self, Net, Peer, TorProxyStatus}, state::{self, State}, types::{ Address, AmountOverflowError, AmountUnderflowError, Authorized, @@ -72,6 +72,8 @@ pub enum Error { NetTask(#[source] Box), #[error("No CUSF mainchain wallet client")] NoCusfMainchainWalletClient, + #[error("block {block_hash} is not on the current canonical chain")] + NonCanonicalBlock { block_hash: BlockHash }, #[error("peer info stream closed")] PeerInfoRxClosed, #[error("Receive mainchain task response cancelled")] @@ -80,6 +82,8 @@ pub enum Error { SendMainchainTaskRequest, #[error("state error")] State(#[source] Box), + #[error("Tor proxy mode has no connected tunnel peer")] + TorProxyUnavailable, #[error("Utreexo error: {0}")] Utreexo(String), #[error("Verify BMM error")] @@ -116,6 +120,25 @@ impl From for Error { pub type FilledTransactionWithPosition = (Authorized, Option); +fn ensure_canonical_block( + archive: &Archive, + rotxn: &sneed::RoTxn, + tip: Option, + block_hash: BlockHash, +) -> Result<(), Error> { + let is_canonical = if let Some(tip) = tip + && archive.try_get_height(rotxn, block_hash)?.is_some() + { + archive.is_descendant(rotxn, block_hash, tip)? + } else { + false + }; + if !is_canonical { + return Err(Error::NonCanonicalBlock { block_hash }); + } + Ok(()) +} + #[derive(Clone)] pub struct Node { archive: Archive, @@ -139,6 +162,8 @@ where #[allow(clippy::too_many_arguments)] pub async fn new( bind_addr: SocketAddr, + tor_proxy_mode: bool, + tor_proxy_peer: Option, datadir: &Path, network: Network, cusf_mainchain: mainchain::ValidatorClient, @@ -203,8 +228,15 @@ where archive.clone(), cusf_mainchain.clone(), ); - let (net, peer_info_rx) = - Net::new(&env, archive.clone(), network, state.clone(), bind_addr)?; + let (net, peer_info_rx) = Net::new( + &env, + archive.clone(), + network, + state.clone(), + bind_addr, + tor_proxy_mode, + tor_proxy_peer, + )?; let cusf_mainchain_wallet = cusf_mainchain_wallet.map(|wallet| Arc::new(Mutex::new(wallet))); let net_task = NetTaskHandle::new( @@ -320,8 +352,8 @@ where Ok(res) } - /** Resolve bitname data at the specified block height. - * Returns an error if it does not exist.rror if it does not exist. */ + /** Resolve BitName data at the specified block height. + * Returns an error if it does not exist. */ pub fn get_bitname_data_at_block_height( &self, bitname: &BitName, @@ -335,6 +367,37 @@ where .map_err(state::Error::BitName)?) } + /// Resolve BitName data at an exact transaction position within a block. + pub fn get_bitname_data_at_block_position( + &self, + bitname: &BitName, + block_hash: BlockHash, + tx_index: u32, + ) -> Result { + let rotxn = self.env.read_txn()?; + let tip = self.state.try_get_tip(&rotxn)?; + ensure_canonical_block(&self.archive, &rotxn, tip, block_hash)?; + let height = self.archive.get_height(&rotxn, block_hash)?; + let body = self.archive.get_body(&rotxn, block_hash)?; + let tx_indexes = body + .transactions + .iter() + .enumerate() + .map(|(index, transaction)| (transaction.txid(), index as u32)) + .collect::>(); + Ok(self + .state + .bitnames() + .get_bitname_data_at_block_position( + &rotxn, + bitname, + height, + tx_index, + &tx_indexes, + ) + .map_err(state::Error::BitName)?) + } + /// resolve current bitname data, if it exists pub fn try_get_current_bitname_data( &self, @@ -365,13 +428,22 @@ where &self, transaction: AuthorizedTransaction, ) -> Result<(), Error> { + let tor_proxy_status = self.net.tor_proxy_status(); + if !tor_proxy_status.allows_transaction_submission() { + return Err(Error::TorProxyUnavailable); + } + let txid = transaction.transaction.txid(); { let mut rotxn = self.env.write_txn()?; self.state.validate_transaction(&rotxn, &transaction)?; self.mempool.put(&mut rotxn, &transaction)?; rotxn.commit().map_err(RwTxnError::from)?; } - self.net.push_tx(Default::default(), transaction); + let queued_peers = self.net.push_tx(Default::default(), transaction); + if tor_proxy_status.tor_proxy_mode && queued_peers == 0 { + self.remove_from_mempool(txid)?; + return Err(Error::TorProxyUnavailable); + } Ok(()) } @@ -695,6 +767,10 @@ where self.net.get_active_peers() } + pub fn tor_proxy_status(&self) -> TorProxyStatus { + self.net.tor_proxy_status() + } + pub async fn request_mainchain_ancestor_infos( &self, block_hash: bitcoin::BlockHash, @@ -841,3 +917,91 @@ where self.state.watch() } } + +#[cfg(test)] +mod tests { + use bitcoin::hashes::Hash as _; + + use super::*; + + fn header(prev_side_hash: Option, marker: u8) -> Header { + Header { + merkle_root: [marker; 32].into(), + prev_side_hash, + prev_main_hash: bitcoin::BlockHash::all_zeros(), + } + } + + #[test] + fn canonical_guard_rejects_old_branch_after_reorg() -> anyhow::Result<()> { + let temp_dir = + temp_dir::TempDir::with_prefix("plain-bitnames-node-canonical")?; + let mut opts = heed::EnvOpenOptions::new(); + opts.map_size(64 * 1024 * 1024).max_dbs(Archive::NUM_DBS); + let env = unsafe { sneed::Env::open(&opts, temp_dir.path()) }?; + let archive = Archive::new(&env)?; + + let genesis = header(None, 1); + let genesis_hash = genesis.hash(); + let branch_a = header(Some(genesis_hash), 2); + let branch_a_hash = branch_a.hash(); + let branch_b = header(Some(genesis_hash), 3); + let branch_b_hash = branch_b.hash(); + let mut rwtxn = env.write_txn()?; + archive.put_header(&mut rwtxn, &genesis)?; + archive.put_header(&mut rwtxn, &branch_a)?; + archive.put_header(&mut rwtxn, &branch_b)?; + rwtxn.commit()?; + + let rotxn = env.read_txn()?; + assert!( + ensure_canonical_block( + &archive, + &rotxn, + Some(branch_a_hash), + genesis_hash, + ) + .is_ok() + ); + assert!( + ensure_canonical_block( + &archive, + &rotxn, + Some(branch_a_hash), + branch_a_hash, + ) + .is_ok() + ); + assert!(matches!( + ensure_canonical_block( + &archive, + &rotxn, + Some(branch_a_hash), + branch_b_hash, + ), + Err(Error::NonCanonicalBlock { block_hash }) + if block_hash == branch_b_hash + )); + + assert!( + ensure_canonical_block( + &archive, + &rotxn, + Some(branch_b_hash), + branch_b_hash, + ) + .is_ok() + ); + assert!(matches!( + ensure_canonical_block( + &archive, + &rotxn, + Some(branch_b_hash), + branch_a_hash, + ), + Err(Error::NonCanonicalBlock { block_hash }) + if block_hash == branch_a_hash + )); + Ok(()) + } +} diff --git a/lib/node/net_task.rs b/lib/node/net_task.rs index a5dbd75..e423631 100644 --- a/lib/node/net_task.rs +++ b/lib/node/net_task.rs @@ -1169,7 +1169,7 @@ impl NetTask { self.ctxt.mempool.put(&mut rwtxn, &new_tx)?; rwtxn.commit().map_err(RwTxnError::from)?; // broadcast - let () = self + let _queued_peers = self .ctxt .net .push_tx(HashSet::from_iter([addr]), new_tx); @@ -1195,6 +1195,13 @@ impl NetTask { } } MailboxItem::ReconnectPeer(peer_address) => { + if !self.ctxt.net.peer_address_allowed(peer_address) { + tracing::warn!( + %peer_address, + "refusing to reconnect directly in Tor proxy mode" + ); + continue; + } match self .ctxt .net diff --git a/lib/state/bitnames.rs b/lib/state/bitnames.rs index 2d1818f..1000821 100644 --- a/lib/state/bitnames.rs +++ b/lib/state/bitnames.rs @@ -1,6 +1,9 @@ //! Functions and types related to BitNames -use std::net::{SocketAddrV4, SocketAddrV6}; +use std::{ + collections::HashMap, + net::{SocketAddrV4, SocketAddrV6}, +}; use heed::types::SerdeBincode; use serde::{Deserialize, Serialize}; @@ -262,6 +265,46 @@ impl BitNameData { }) } + /// Returns the BitName data as it was after the transaction at `tx_index` + /// in a block. + pub fn at_block_position( + &self, + height: u32, + tx_index: u32, + tx_indexes: &HashMap, + ) -> Option { + let mutable_data = crate::types::MutableBitNameData { + commitment: self + .commitment + .at_block_position(height, tx_index, tx_indexes)? + .data, + socket_addr_v4: self + .socket_addr_v4 + .at_block_position(height, tx_index, tx_indexes)? + .data, + socket_addr_v6: self + .socket_addr_v6 + .at_block_position(height, tx_index, tx_indexes)? + .data, + encryption_pubkey: self + .encryption_pubkey + .at_block_position(height, tx_index, tx_indexes)? + .data, + signing_pubkey: self + .signing_pubkey + .at_block_position(height, tx_index, tx_indexes)? + .data, + paymail_fee_sats: self + .paymail_fee_sats + .at_block_position(height, tx_index, tx_indexes)? + .data, + }; + Some(crate::types::BitNameData { + seq_id: self.seq_id, + mutable_data, + }) + } + /// get the current bitname data pub fn current(&self) -> crate::types::BitNameData { let mutable_data = crate::types::MutableBitNameData { @@ -398,6 +441,23 @@ impl Dbs { }) } + /// Resolve BitName data at an exact transaction position within a block. + pub fn get_bitname_data_at_block_position( + &self, + rotxn: &RoTxn, + bitname: &BitName, + height: u32, + tx_index: u32, + tx_indexes: &HashMap, + ) -> Result { + self.get_bitname(rotxn, bitname)? + .at_block_position(height, tx_index, tx_indexes) + .ok_or(Error::MissingData { + bitname: *bitname, + block_height: height, + }) + } + /// resolve current bitname data, if it exists pub fn try_get_current_bitname_data( &self, diff --git a/lib/state/rollback.rs b/lib/state/rollback.rs index fe49f36..485055f 100644 --- a/lib/state/rollback.rs +++ b/lib/state/rollback.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use nonempty::NonEmpty; use serde::{Deserialize, Serialize}; @@ -104,8 +106,48 @@ impl RollBack> { .find(|txid_stamped| txid_stamped.height <= height) } + /// Returns the value as it was after the transaction at `tx_index` in a + /// block. This avoids applying a later update from the same block. + pub fn at_block_position( + &self, + height: u32, + tx_index: u32, + tx_indexes: &HashMap, + ) -> Option<&TxidStamped> { + self.0.iter().rev().find(|txid_stamped| { + txid_stamped.height < height + || (txid_stamped.height == height + && tx_indexes + .get(&txid_stamped.txid) + .is_some_and(|update_index| *update_index <= tx_index)) + }) + } + /// returns the most recent value, along with it's txid pub fn latest(&self) -> &TxidStamped { self.0.last() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn block_position_excludes_later_update_in_same_block() { + let first_txid = Txid([1; 32]); + let later_txid = Txid([2; 32]); + let mut history = RollBack::>::new(10, first_txid, 7); + history.push(20, later_txid, 7); + let tx_indexes = HashMap::from([(first_txid, 1), (later_txid, 3)]); + + assert_eq!( + history.at_block_position(7, 2, &tx_indexes).unwrap().data, + 10 + ); + assert_eq!( + history.at_block_position(7, 3, &tx_indexes).unwrap().data, + 20 + ); + } +} diff --git a/lib/wallet/error.rs b/lib/wallet/error.rs index 9dad702..cca8850 100644 --- a/lib/wallet/error.rs +++ b/lib/wallet/error.rs @@ -6,8 +6,8 @@ use thiserror::Error; use transitive::Transitive; use crate::types::{ - Address, AmountOverflowError, AmountUnderflowError, EncryptionPubKey, - VerifyingKey, + Address, AmountOverflowError, AmountUnderflowError, BitName, + EncryptionPubKey, VerifyingKey, }; #[derive(Debug, Error)] @@ -107,6 +107,8 @@ pub enum Error { Authorization(#[from] crate::authorization::Error), #[error("bip32 error")] Bip32(#[from] bitcoin::bip32::Error), + #[error("BitName {bitname} is not owned by this wallet")] + BitNameNotOwned { bitname: BitName }, #[error(transparent)] Db(#[from] db::Error), #[error("Database env error")] @@ -119,6 +121,10 @@ pub enum Error { EpkDoesNotExist(#[from] EpkDoesNotExist), #[error("io error")] Io(#[from] std::io::Error), + #[error( + "idempotency key `{key}` was already used for a different transfer" + )] + IdempotencyConflict { key: String }, #[error("no index for address {address}")] NoIndex { address: Address }, #[error(transparent)] diff --git a/lib/wallet/mod.rs b/lib/wallet/mod.rs index f7fe125..5d4e3da 100644 --- a/lib/wallet/mod.rs +++ b/lib/wallet/mod.rs @@ -22,11 +22,11 @@ use crate::{ authorization::{self, Authorization, Signature, get_address}, types::{ Address, AmountOverflowError, AuthorizedTransaction, - BitcoinOutputContent, EncryptionPubKey, FilledOutput, GetValue, Hash, - InPoint, MutableBitNameData, OutPoint, OutPointKey, Output, - OutputContent, SpentOutput, Transaction, TxData, VERSION, VerifyingKey, - Version, WithdrawalOutputContent, XEncryptionSecretKey, XVerifyingKey, - hashes::BitName, keys::Ecies, + BitNameDataUpdates, BitcoinOutputContent, EncryptionPubKey, + FilledOutput, GetValue, Hash, InPoint, MutableBitNameData, OutPoint, + OutPointKey, Output, OutputContent, SpentOutput, Transaction, TxData, + VERSION, VerifyingKey, Version, WithdrawalOutputContent, + XEncryptionSecretKey, XVerifyingKey, hashes::BitName, keys::Ecies, }, util::Watchable, }; @@ -51,6 +51,15 @@ pub struct Balance { pub available: Amount, } +#[derive(Clone, Debug, Deserialize, Serialize)] +struct TransferIntent { + address: Address, + value_sats: u64, + fee_sats: u64, + memo: Option>, + transaction: Transaction, +} + #[derive(Debug, Error)] #[error("Message signature verification key {vk} does not exist")] pub struct VkDoesNotExistError { @@ -88,13 +97,14 @@ pub struct Wallet { bitname_reservations: DatabaseUnique, Str>, /// Associates BitNames with plaintext names known_bitnames: DatabaseUnique, Str>, + transfer_intents: DatabaseUnique>, /// Map each verifying key to it's index vk_to_index: DatabaseUnique, U32>, _version: DatabaseUnique>, } impl Wallet { - pub const NUM_DBS: u32 = 12; + pub const NUM_DBS: u32 = 13; pub fn new(path: &Path) -> Result { std::fs::create_dir_all(path)?; @@ -146,6 +156,8 @@ impl Wallet { DatabaseUnique::create(&env, &mut rwtxn, "bitname_reservations")?; let known_bitnames = DatabaseUnique::create(&env, &mut rwtxn, "known_bitnames")?; + let transfer_intents = + DatabaseUnique::create(&env, &mut rwtxn, "transfer_intents")?; let vk_to_index = DatabaseUnique::create(&env, &mut rwtxn, "vk_to_index")?; let version = DatabaseUnique::create(&env, &mut rwtxn, "version")?; @@ -165,6 +177,7 @@ impl Wallet { stxos, bitname_reservations, known_bitnames, + transfer_intents, vk_to_index, _version: version, }) @@ -567,6 +580,87 @@ impl Wallet { Ok(Transaction::new(inputs, outputs)) } + pub fn create_idempotent_transfer( + &self, + key: &str, + address: Address, + value: bitcoin::Amount, + fee: bitcoin::Amount, + memo: Option>, + ) -> Result { + let matches = |intent: &TransferIntent| { + intent.address == address + && intent.value_sats == value.to_sat() + && intent.fee_sats == fee.to_sat() + && intent.memo == memo + }; + let rotxn = self.env.read_txn()?; + if let Some(intent) = self.transfer_intents.try_get(&rotxn, key)? { + return matches(&intent).then_some(intent.transaction).ok_or_else( + || Error::IdempotencyConflict { + key: key.to_owned(), + }, + ); + } + drop(rotxn); + let transaction = + self.create_transfer(address, value, fee, memo.clone())?; + let intent = TransferIntent { + address, + value_sats: value.to_sat(), + fee_sats: fee.to_sat(), + memo: memo.clone(), + transaction, + }; + let mut rwtxn = self.env.write_txn()?; + if let Some(existing) = self.transfer_intents.try_get(&rwtxn, key)? { + return matches(&existing) + .then_some(existing.transaction) + .ok_or_else(|| Error::IdempotencyConflict { + key: key.to_owned(), + }); + } + self.transfer_intents.put(&mut rwtxn, key, &intent)?; + rwtxn.commit()?; + Ok(intent.transaction) + } + + /// Create a transaction that updates mutable data for an owned BitName + /// while retaining ownership at the existing BitName output address. + pub fn create_bitname_update( + &self, + bitname: BitName, + updates: BitNameDataUpdates, + fee: bitcoin::Amount, + ) -> Result { + let (bitname_outpoint, bitname_output) = self + .get_bitnames()? + .into_iter() + .find(|(_, output)| output.bitname() == Some(&bitname)) + .ok_or(Error::BitNameNotOwned { bitname })?; + + let (total, coins) = self.select_coins(fee)?; + let change = total - fee; + let mut inputs: Vec<_> = coins.into_keys().collect(); + inputs.push(bitname_outpoint); + + let mut outputs = Vec::with_capacity(2); + if change != Amount::ZERO { + outputs.push(Output::new( + self.get_new_address()?, + OutputContent::Bitcoin(BitcoinOutputContent(change)), + )); + } + // Keep the BitName output last. State transition logic defines the last + // spent/recreated BitName as the record being updated. + outputs + .push(Output::new(bitname_output.address, OutputContent::BitName)); + + let mut transaction = Transaction::new(inputs, outputs); + transaction.data = Some(TxData::BitNameUpdate(Box::new(updates))); + Ok(transaction) + } + /// given a regular transaction, add a bitname reservation. /// given a bitname reservation tx, change the reserved name. /// panics if the tx is not regular or a bitname reservation tx. @@ -986,6 +1080,7 @@ impl Watchable<()> for Wallet { stxos, bitname_reservations, known_bitnames, + transfer_intents, vk_to_index, _version: _, } = self; @@ -1000,6 +1095,7 @@ impl Watchable<()> for Wallet { stxos.watch().clone(), bitname_reservations.watch().clone(), known_bitnames.watch().clone(), + transfer_intents.watch().clone(), vk_to_index.watch().clone(), ]; let streams = StreamMap::from_iter( @@ -1016,15 +1112,28 @@ impl Watchable<()> for Wallet { #[cfg(test)] mod test { - use crate::wallet::Wallet; + use std::collections::HashMap; - #[test] - fn test_get_or_generate_last_address() -> anyhow::Result<()> { + use bitcoin::Amount; + + use crate::{ + types::{ + BitName, BitNameDataUpdates, FilledOutput, FilledOutputContent, + GetValue, OutPoint, OutputContent, TxData, Txid, Update, + }, + wallet::Wallet, + }; + + fn test_wallet_dir(test_name: &str) -> anyhow::Result { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_nanos(); - let test_dir = - std::env::temp_dir().join(format!("bitnames_test_wallet_{nanos}")); + Ok(std::env::temp_dir().join(format!("bitnames_{test_name}_{nanos}"))) + } + + #[test] + fn test_get_or_generate_last_address() -> anyhow::Result<()> { + let test_dir = test_wallet_dir("test_wallet")?; // Ensure clean state if test_dir.exists() { @@ -1065,4 +1174,123 @@ mod test { let _unused = std::fs::remove_dir_all(&test_dir); Ok(()) } + + #[test] + fn create_bitname_update_retains_owner_and_pays_fee() -> anyhow::Result<()> + { + let test_dir = test_wallet_dir("update_bitname")?; + let wallet = Wallet::new(&test_dir)?; + wallet.set_seed(&[2u8; 64])?; + + let owner_address = wallet.get_new_address()?; + let funding_address = wallet.get_new_address()?; + let bitname = BitName([3u8; 32]); + let bitname_outpoint = OutPoint::Regular { + txid: Txid([4u8; 32]), + vout: 0, + }; + let funding_outpoint = OutPoint::Regular { + txid: Txid([5u8; 32]), + vout: 0, + }; + wallet.put_utxos(&HashMap::from([ + ( + bitname_outpoint, + FilledOutput { + address: owner_address, + content: FilledOutputContent::BitName(bitname), + memo: Vec::new(), + }, + ), + ( + funding_outpoint, + FilledOutput::new_bitcoin_value( + funding_address, + Amount::from_sat(1_000), + ), + ), + ]))?; + + let updates = BitNameDataUpdates { + commitment: Update::Retain, + socket_addr_v4: Update::Retain, + socket_addr_v6: Update::Retain, + encryption_pubkey: Update::Retain, + signing_pubkey: Update::Retain, + paymail_fee_sats: Update::Set(2_000), + }; + let transaction = wallet.create_bitname_update( + bitname, + updates, + Amount::from_sat(100), + )?; + + assert_eq!(transaction.inputs.len(), 2); + assert!(transaction.inputs.contains(&bitname_outpoint)); + assert!(transaction.inputs.contains(&funding_outpoint)); + assert_eq!(transaction.outputs.len(), 2); + assert_eq!(transaction.outputs[0].get_value(), Amount::from_sat(900)); + let bitname_output = transaction.outputs.last().unwrap(); + assert_eq!(bitname_output.address, owner_address); + assert!(matches!(bitname_output.content, OutputContent::BitName)); + assert!(matches!( + transaction.data, + Some(TxData::BitNameUpdate(updates)) + if matches!(updates.paymail_fee_sats, Update::Set(2_000)) + )); + + let _unused = std::fs::remove_dir_all(&test_dir); + Ok(()) + } + + #[test] + fn idempotent_transfer_survives_restart_and_rejects_key_reuse() + -> anyhow::Result<()> { + let test_dir = test_wallet_dir("idempotent_transfer")?; + let wallet = Wallet::new(&test_dir)?; + wallet.set_seed(&[6u8; 64])?; + let funding_address = wallet.get_new_address()?; + let destination = wallet.get_new_address()?; + wallet.put_utxos(&HashMap::from([( + OutPoint::Regular { + txid: Txid([7u8; 32]), + vout: 0, + }, + FilledOutput::new_bitcoin_value( + funding_address, + Amount::from_sat(1_000), + ), + )]))?; + let first = wallet.create_idempotent_transfer( + "message-id", + destination, + Amount::from_sat(1), + Amount::from_sat(100), + Some(vec![1, 2, 3]), + )?; + drop(wallet); + let wallet = Wallet::new(&test_dir)?; + let retry = wallet.create_idempotent_transfer( + "message-id", + destination, + Amount::from_sat(1), + Amount::from_sat(100), + Some(vec![1, 2, 3]), + )?; + assert_eq!(first.txid(), retry.txid()); + assert!( + wallet + .create_idempotent_transfer( + "message-id", + destination, + Amount::from_sat(2), + Amount::from_sat(100), + Some(vec![1, 2, 3]), + ) + .is_err() + ); + drop(wallet); + let _unused = std::fs::remove_dir_all(&test_dir); + Ok(()) + } } diff --git a/rpc-api/Cargo.toml b/rpc-api/Cargo.toml index 3c774d2..0c002a0 100644 --- a/rpc-api/Cargo.toml +++ b/rpc-api/Cargo.toml @@ -18,6 +18,7 @@ utoipa = { workspace = true } [dev-dependencies] anyhow = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } [lints] workspace = true diff --git a/rpc-api/lib.rs b/rpc-api/lib.rs index 2a69bc9..d2b7f75 100644 --- a/rpc-api/lib.rs +++ b/rpc-api/lib.rs @@ -6,13 +6,14 @@ use jsonrpsee::{core::RpcResult, proc_macros::rpc}; use l2l_openapi::open_api; use plain_bitnames::{ authorization::{Dst, Signature}, - net::{Peer, PeerConnectionStatus}, + net::{Peer, PeerConnectionStatus, TorProxyStatus}, types::{ Address, Authorization, BatchIcannRegistrationData, BitNameData, - BitNameDataUpdates, BitNameSeqId, BitcoinOutputContent, Block, - BlockHash, Body, EncryptionPubKey, FilledOutput, FilledOutputContent, - Header, InPoint, M6id, MerkleRoot, MutableBitNameData, OutPoint, - Output, OutputContent, PointedOutput, SpentOutput, Transaction, + BitNameDataUpdates, BitNameResolution, BitNameSeqId, + BitcoinOutputContent, Block, BlockHash, Body, EncryptionPubKey, + FilledOutput, FilledOutputContent, Header, InPoint, M6id, MerkleRoot, + MutableBitNameData, OutPoint, Output, OutputContent, PaymailEntry, + PaymailRecipient, PointedOutput, SpentOutput, Transaction, TransactionData, TxIn, Txid, VerifyingKey, WithdrawalBundle, WithdrawalOutputContent, XEncryptionSecretKey, XVerifyingKey, hashes::BitName, schema as bitnames_schema, @@ -38,12 +39,12 @@ pub struct TxInfo { bitnames_schema::BitcoinAddr, bitnames_schema::BitcoinBlockHash, bitnames_schema::BitcoinOutPoint, bitnames_schema::BitcoinTransaction, bitnames_schema::SocketAddr, Address, Authorization, - BatchIcannRegistrationData, BitcoinOutputContent, BitName, + BatchIcannRegistrationData, BitcoinOutputContent, BitName, BitNameData, BitNameDataUpdates, BitNameSeqId, BlockHash, Body, EncryptionPubKey, FilledOutput, FilledOutputContent, Header, InPoint, M6id, MerkleRoot, - MutableBitNameData, OutPoint, Output, OutputContent, PeerConnectionStatus, - Signature, SpentOutput, Transaction, TransactionData, Txid, TxIn, - VerifyingKey, WithdrawalOutputContent, + MutableBitNameData, OutPoint, Output, OutputContent, PaymailRecipient, + PeerConnectionStatus, Signature, SpentOutput, Transaction, TransactionData, + Txid, TxIn, VerifyingKey, WithdrawalOutputContent, ])] #[rpc(client, server)] pub trait Rpc { @@ -57,6 +58,15 @@ pub trait Rpc { async fn bitname_data(&self, bitname_id: BitName) -> RpcResult; + /// Retrieve BitName data after a transaction in a canonical block. + #[method(name = "bitname_data_at_position")] + async fn bitname_data_at_position( + &self, + bitname: BitName, + block_hash: BlockHash, + tx_index: u32, + ) -> RpcResult; + /// List all BitNames #[open_api_method(output_schema( PartialSchema = "schema::ArrayTuple" @@ -64,6 +74,13 @@ pub trait Rpc { #[method(name = "bitnames")] async fn bitnames(&self) -> RpcResult>; + /// Resolve a BitName to its current owner output, address, and mutable data. + #[method(name = "resolve_bitname")] + async fn resolve_bitname( + &self, + bitname: BitName, + ) -> RpcResult; + /// Deposit to address #[open_api_method(output_schema(PartialSchema = "schema::BitcoinTxid"))] #[method(name = "create_deposit")] @@ -175,6 +192,12 @@ pub trait Rpc { #[method(name = "get_paymail")] async fn get_paymail(&self) -> RpcResult>; + /// Get JSON-safe paymail entries, including spent-output history and the + /// exact BitNames that owned each output address at confirmation time. + /// Under-fee entries are included for local accepted-contact policy. + #[method(name = "get_paymail_entries")] + async fn get_paymail_entries(&self) -> RpcResult>; + /// Get transaction by txid #[method(name = "get_transaction")] async fn get_transaction( @@ -221,6 +244,12 @@ pub trait Rpc { #[method(name = "list_peers")] async fn list_peers(&self) -> RpcResult>; + /// Return whether Tor proxy mode is active and how many tunnel peers are + /// connected. + #[open_api_method(output_schema(ToSchema))] + #[method(name = "tor_proxy_status")] + async fn tor_proxy_status(&self) -> RpcResult; + /// List all STXOs #[open_api_method(output_schema( ToSchema = "Vec>" @@ -268,6 +297,15 @@ pub trait Rpc { #[method(name = "reserve_bitname")] async fn reserve_bitname(&self, plain_name: String) -> RpcResult; + /// Update mutable data for an owned BitName while retaining ownership. + #[method(name = "update_bitname")] + async fn update_bitname( + &self, + bitname: BitName, + updates: BitNameDataUpdates, + fee_sats: u64, + ) -> RpcResult; + /// Set the wallet seed from a mnemonic seed phrase #[open_api_method(output_schema(ToSchema))] #[method(name = "set_seed_from_mnemonic")] @@ -305,6 +343,7 @@ pub trait Rpc { value: u64, fee: u64, memo: Option, + idempotency_key: Option, ) -> RpcResult; /// Verify a signature on a message against the specified verifying key. diff --git a/rpc-api/test.rs b/rpc-api/test.rs index 37cb2f9..6712349 100644 --- a/rpc-api/test.rs +++ b/rpc-api/test.rs @@ -2,6 +2,8 @@ use std::collections::BTreeSet; use utoipa::openapi::{self, Ref, RefOr, Schema}; +use crate::RpcClient as _; + /// Get all component refs trait ComponentRefs { fn component_refs(&self) -> impl Iterator + '_; @@ -308,3 +310,47 @@ fn check_schema() -> anyhow::Result<()> { } Ok(()) } + +#[tokio::test(flavor = "current_thread")] +async fn bitname_data_at_position_rpc_contract() -> anyhow::Result<()> { + use jsonrpsee::{RpcModule, http_client::HttpClientBuilder}; + use plain_bitnames::types::{ + BitName, BitNameData, BitNameSeqId, BlockHash, MutableBitNameData, + }; + + let bitname = BitName([1; 32]); + let block_hash = BlockHash([2; 32]); + let tx_index = 3; + let expected = BitNameData { + seq_id: BitNameSeqId::new(4), + mutable_data: MutableBitNameData { + commitment: Some([5; 32]), + paymail_fee_sats: Some(1), + ..Default::default() + }, + }; + let response = expected.clone(); + let mut module = RpcModule::new(()); + module.register_method( + "bitname_data_at_position", + move |params, _, _| { + let received: (BitName, BlockHash, u32) = params.parse()?; + assert_eq!(received, (bitname, block_hash, tx_index)); + jsonrpsee::core::RpcResult::Ok(response.clone()) + }, + )?; + let server = jsonrpsee::server::Server::builder() + .build("127.0.0.1:0") + .await?; + let addr = server.local_addr()?; + let handle = server.start(module); + let client = + HttpClientBuilder::default().build(format!("http://{addr}"))?; + + let actual = client + .bitname_data_at_position(bitname, block_hash, tx_index) + .await?; + assert_eq!(actual, expected); + handle.stop()?; + Ok(()) +} diff --git a/types/lib.rs b/types/lib.rs index 87a59e5..591ce03 100644 --- a/types/lib.rs +++ b/types/lib.rs @@ -21,6 +21,7 @@ pub mod bitname_seq_id; pub mod constants; pub mod hashes; pub mod keys; +mod paymail; pub mod schema; pub mod transaction; @@ -34,6 +35,7 @@ pub use keys::{ EncryptionPubKey, VerifyingKey, XEncryptionSecretKey, XPubKey, XVerifyingKey, }; +pub use paymail::{BitNameResolution, PaymailEntry, PaymailRecipient}; pub use transaction::{ Authorized, AuthorizedTransaction, BatchIcannRegistrationData, BitcoinOutputContent, Content as OutputContent, diff --git a/types/paymail.rs b/types/paymail.rs new file mode 100644 index 0000000..b130b3b --- /dev/null +++ b/types/paymail.rs @@ -0,0 +1,123 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::{Address, BitName, BitNameData, BlockHash, FilledOutput, OutPoint}; + +/// The current on-chain owner and mutable data for a BitName. +/// +/// BitName ownership is represented by a zero-value BitName UTXO. The address +/// on that output, rather than a field in [`BitNameData`], is the address to +/// which paymail should be sent. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] +pub struct BitNameResolution { + pub bitname: BitName, + pub outpoint: OutPoint, + pub address: Address, + pub data: BitNameData, +} + +/// A BitName that owned the mailbox output address when it was confirmed. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] +pub struct PaymailRecipient { + pub bitname: BitName, + /// Advertised minimum paymail fee at confirmation time. `None` means the + /// BitName was not accepting paid introductions at that time. + pub required_fee_sats: Option, + pub data: BitNameData, +} + +/// A JSON-safe mailbox entry. +/// +/// Unlike the legacy `HashMap` response, this type does +/// not encode an enum as a JSON object key. It also identifies the exact +/// BitName records that owned the output address at confirmation time. Entries +/// below the advertised fee are intentionally included so callers can apply a +/// local accepted-contact policy for low-value follow-up messages. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] +pub struct PaymailEntry { + pub outpoint: OutPoint, + pub output: FilledOutput, + pub value_sats: u64, + pub block_hash: BlockHash, + pub block_height: u32, + pub tx_index: u32, + pub recipients: Vec, +} + +impl PaymailEntry { + /// Whether this output paid at least the advertised fee for any attributed + /// recipient. Typed mailbox consumers deliberately receive entries for + /// which this is false so they can apply local accepted-contact policy. + pub fn meets_advertised_fee(&self) -> bool { + self.recipients.iter().any(|recipient| { + recipient + .required_fee_sats + .is_some_and(|required_fee| self.value_sats >= required_fee) + }) + } +} + +#[cfg(test)] +mod tests { + use bitcoin::Amount; + + use crate::{ + Address, BitName, BitNameData, BitNameSeqId, BlockHash, FilledOutput, + FilledOutputContent, MutableBitNameData, OutPoint, PaymailEntry, + PaymailRecipient, Txid, + }; + + fn underpaid_entry() -> PaymailEntry { + let bitname = BitName([1; 32]); + let data = BitNameData { + seq_id: BitNameSeqId::new(0), + mutable_data: MutableBitNameData { + paymail_fee_sats: Some(1_000), + ..Default::default() + }, + }; + PaymailEntry { + outpoint: OutPoint::Regular { + txid: Txid([2; 32]), + vout: 0, + }, + output: FilledOutput { + address: Address([3; 20]), + content: FilledOutputContent::new_bitcoin_value( + Amount::from_sat(1), + ), + memo: vec![0xaa, 0xbb], + }, + value_sats: 1, + block_hash: BlockHash([4; 32]), + block_height: 5, + tx_index: 6, + recipients: vec![PaymailRecipient { + bitname, + required_fee_sats: Some(1_000), + data, + }], + } + } + + #[test] + fn underpaid_entry_remains_json_safe_and_attributed() { + let entry = underpaid_entry(); + assert!(!entry.meets_advertised_fee()); + + let json = serde_json::to_value([entry]).unwrap(); + let entry = &json[0]; + assert_eq!(entry["value_sats"], 1); + assert_eq!(entry["output"]["memo"], "aabb"); + assert_eq!(entry["recipients"][0]["required_fee_sats"], 1_000); + assert_eq!(entry["recipients"][0]["bitname"], hex::encode([1; 32])); + assert!(entry["outpoint"].get("Regular").is_some()); + } + + #[test] + fn advertised_fee_is_a_separate_legacy_policy() { + let mut entry = underpaid_entry(); + entry.value_sats = 1_000; + assert!(entry.meets_advertised_fee()); + } +}