Skip to content

Commit 4afabfe

Browse files
committed
Add payjoin v2 receiver flow
Implements the receiver side of the BIP 77 Payjoin v2 protocol, allowing LDK Node users to receive payjoin payments via a payjoin directory and OHTTP relay. - Adds a `PayjoinPayment` handler exposing a `receive()` method that returns a BIP 21 URI the sender can use to initiate the payjoin flow. The full receiver state machine is implemented covering all `ReceiveSession` states: polling the directory, validating the sender's proposal, contributing inputs, finalizing the PSBT, and monitoring the mempool. - Session state is persisted via `KVStorePayjoinReceiverPersister` and survives node restarts through event log replay. Sender inputs are tracked by `OutPoint` across polling attempts to prevent replay attacks. The sender's fallback transaction is broadcast on cancellation or failure to ensure the receiver still gets paid. - Adds `PaymentKind::Payjoin` to the payment store, `PayjoinConfig` for configuring the payjoin directory and OHTTP relay via `Builder::set_payjoin_config`, and background tasks for session resumption every 15 seconds and cleanup of terminal sessions after 24 hours.
1 parent 109978d commit 4afabfe

19 files changed

Lines changed: 1865 additions & 14 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ prost = { version = "0.11.6", default-features = false}
8383
#bitcoin-payment-instructions = { version = "0.6" }
8484
bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "679dac50cc0d81ec4d31da94b93d467e5308f16a" }
8585

86+
payjoin = { git = "https://github.com/payjoin/rust-payjoin.git", rev= "dd21835e55116c681fddae29bf3cd363dd177745", package = "payjoin", default-features = false, features = ["v2", "io"] }
87+
8688
[target.'cfg(windows)'.dependencies]
8789
winapi = { version = "0.3", features = ["winbase"] }
8890

bindings/ldk_node.udl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ interface Node {
9797
SpontaneousPayment spontaneous_payment();
9898
OnchainPayment onchain_payment();
9999
UnifiedPayment unified_payment();
100+
[Throws=NodeError]
101+
PayjoinPayment payjoin_payment();
100102
LSPS1Liquidity lsps1_liquidity();
101103
[Throws=NodeError]
102104
void lnurl_auth(string lnurl);
@@ -165,6 +167,8 @@ interface FeeRate {
165167

166168
typedef interface UnifiedPayment;
167169

170+
typedef interface PayjoinPayment;
171+
168172
typedef interface LSPS1Liquidity;
169173

170174
[Error]
@@ -192,6 +196,8 @@ enum NodeError {
192196
"OnchainTxSigningFailed",
193197
"TxSyncFailed",
194198
"TxSyncTimeout",
199+
"TxLookupFailed",
200+
"TxLookupTimeout",
195201
"GossipUpdateFailed",
196202
"GossipUpdateTimeout",
197203
"LiquidityRequestFailed",
@@ -229,6 +235,9 @@ enum NodeError {
229235
"LnurlAuthFailed",
230236
"LnurlAuthTimeout",
231237
"InvalidLnurl",
238+
"PayjoinNotConfigured",
239+
"PayjoinSessionCreationFailed",
240+
"PayjoinSessionFailed",
232241
};
233242

234243
typedef dictionary NodeStatus;

src/builder.rs

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ use crate::chain::ChainSource;
5050
use crate::config::{
5151
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
5252
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
53-
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
53+
PayjoinConfig, TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
5454
};
5555
use crate::connection::ConnectionManager;
5656
use crate::entropy::NodeEntropy;
@@ -64,7 +64,8 @@ use crate::io::utils::{
6464
};
6565
use crate::io::vss_store::VssStoreBuilder;
6666
use crate::io::{
67-
self, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
67+
self, PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE, PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
68+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
6869
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
6970
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
7071
};
@@ -75,13 +76,14 @@ use crate::lnurl_auth::LnurlAuth;
7576
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
7677
use crate::message_handler::NodeCustomMessageHandler;
7778
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
79+
use crate::payment::payjoin::manager::PayjoinManager;
7880
use crate::peer_store::PeerStore;
7981
use crate::runtime::{Runtime, RuntimeSpawner};
8082
use crate::tx_broadcaster::TransactionBroadcaster;
8183
use crate::types::{
8284
AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper,
83-
GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger, PaymentStore,
84-
PeerManager, PendingPaymentStore, SyncAndAsyncKVStore,
85+
GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger,
86+
PayjoinSessionStore, PaymentStore, PeerManager, PendingPaymentStore, SyncAndAsyncKVStore,
8587
};
8688
use crate::wallet::persist::KVStoreWalletPersister;
8789
use crate::wallet::Wallet;
@@ -200,6 +202,8 @@ pub enum BuildError {
200202
AsyncPaymentsConfigMismatch,
201203
/// An attempt to setup a DNS Resolver failed.
202204
DNSResolverSetupFailed,
205+
/// The payjoin configuration requires a Bitcoin Core backend, but a different chain source was configured.
206+
PayjoinConfigMismatch,
203207
}
204208

205209
impl fmt::Display for BuildError {
@@ -237,6 +241,9 @@ impl fmt::Display for BuildError {
237241
Self::DNSResolverSetupFailed => {
238242
write!(f, "An attempt to setup a DNS resolver has failed.")
239243
},
244+
Self::PayjoinConfigMismatch => {
245+
write!(f, "Payjoin requires a Bitcoin Core chain source, but a different one was configured.")
246+
},
240247
}
241248
}
242249
}
@@ -615,6 +622,15 @@ impl NodeBuilder {
615622
Ok(self)
616623
}
617624

625+
/// Configures the [`Node`] instance to enable payjoin payments.
626+
///
627+
/// The `payjoin_config` specifies the PayJoin directory and OHTTP relay URLs required
628+
/// for payjoin V2 protocol.
629+
pub fn set_payjoin_config(&mut self, payjoin_config: PayjoinConfig) -> &mut Self {
630+
self.config.payjoin_config = Some(payjoin_config);
631+
self
632+
}
633+
618634
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
619635
/// historical wallet funds.
620636
///
@@ -1100,6 +1116,14 @@ impl ArcedNodeBuilder {
11001116
self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ())
11011117
}
11021118

1119+
/// Configures the [`Node`] instance to enable payjoin payments.
1120+
///
1121+
/// The `payjoin_config` specifies the PayJoin directory and OHTTP relay URLs required
1122+
/// for payjoin V2 protocol.
1123+
pub fn set_payjoin_config(&self, payjoin_config: PayjoinConfig) {
1124+
self.inner.write().unwrap().set_payjoin_config(payjoin_config);
1125+
}
1126+
11031127
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
11041128
/// historical wallet funds.
11051129
///
@@ -1288,7 +1312,7 @@ fn build_with_store_internal(
12881312

12891313
let kv_store_ref = Arc::clone(&kv_store);
12901314
let logger_ref = Arc::clone(&logger);
1291-
let (payment_store_res, node_metris_res, pending_payment_store_res) =
1315+
let (payment_store_res, node_metris_res, pending_payment_store_res, payjoin_session_store_res) =
12921316
runtime.block_on(async move {
12931317
tokio::join!(
12941318
read_all_objects(
@@ -1303,6 +1327,12 @@ fn build_with_store_internal(
13031327
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
13041328
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
13051329
Arc::clone(&logger_ref),
1330+
),
1331+
read_all_objects(
1332+
&*kv_store_ref,
1333+
PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE,
1334+
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
1335+
Arc::clone(&logger_ref),
13061336
)
13071337
)
13081338
});
@@ -2024,6 +2054,42 @@ fn build_with_store_internal(
20242054

20252055
let pathfinding_scores_sync_url = pathfinding_scores_sync_config.map(|c| c.url.clone());
20262056

2057+
let payjoin_manager = if config.payjoin_config.is_some() {
2058+
if !matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })) {
2059+
return Err(BuildError::PayjoinConfigMismatch);
2060+
}
2061+
2062+
let payjoin_session_store = match payjoin_session_store_res {
2063+
Ok(payjoin_sessions) => Arc::new(PayjoinSessionStore::new(
2064+
payjoin_sessions,
2065+
PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE.to_string(),
2066+
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE.to_string(),
2067+
Arc::clone(&kv_store),
2068+
Arc::clone(&logger),
2069+
)),
2070+
Err(e) => {
2071+
log_error!(logger, "Failed to read payjoin session data from store: {}", e);
2072+
return Err(BuildError::ReadFailed);
2073+
},
2074+
};
2075+
2076+
Some(Arc::new(PayjoinManager::new(
2077+
Arc::clone(&payjoin_session_store),
2078+
Arc::clone(&logger),
2079+
Arc::clone(&config),
2080+
Arc::clone(&wallet),
2081+
Arc::clone(&fee_estimator),
2082+
Arc::clone(&chain_source),
2083+
Arc::clone(&channel_manager),
2084+
stop_sender.subscribe(),
2085+
Arc::clone(&payment_store),
2086+
Arc::clone(&pending_payment_store),
2087+
Arc::clone(&tx_broadcaster),
2088+
)))
2089+
} else {
2090+
None
2091+
};
2092+
20272093
#[cfg(cycle_tests)]
20282094
let mut _leak_checker = crate::LeakChecker(Vec::new());
20292095
#[cfg(cycle_tests)]
@@ -2071,6 +2137,7 @@ fn build_with_store_internal(
20712137
hrn_resolver,
20722138
#[cfg(cycle_tests)]
20732139
_leak_checker,
2140+
payjoin_manager,
20742141
})
20752142
}
20762143

src/chain/bitcoind.rs

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use serde::Serialize;
3333
use super::WalletSyncStatus;
3434
use crate::config::{
3535
BitcoindRestClientConfig, Config, DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS,
36-
DEFAULT_TX_BROADCAST_TIMEOUT_SECS,
36+
DEFAULT_TX_BROADCAST_TIMEOUT_SECS, DEFAULT_TX_LOOKUP_TIMEOUT_SECS,
3737
};
3838
use crate::fee_estimator::{
3939
apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target,
@@ -610,6 +610,57 @@ impl BitcoindChainSource {
610610
}
611611
}
612612
}
613+
614+
pub(crate) async fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> {
615+
let timeout_fut = tokio::time::timeout(
616+
Duration::from_secs(DEFAULT_TX_LOOKUP_TIMEOUT_SECS),
617+
self.api_client.test_mempool_accept(tx),
618+
);
619+
620+
match timeout_fut.await {
621+
Ok(res) => res.map_err(|e| {
622+
log_error!(
623+
self.logger,
624+
"Failed to test mempool accept for transaction {}: {}",
625+
tx.compute_txid(),
626+
e
627+
);
628+
Error::WalletOperationFailed
629+
}),
630+
Err(e) => {
631+
log_error!(
632+
self.logger,
633+
"Failed to test mempool accept for transaction {} due to timeout: {}",
634+
tx.compute_txid(),
635+
e
636+
);
637+
log_trace!(
638+
self.logger,
639+
"Failed test mempool accept transaction bytes: {}",
640+
log_bytes!(tx.encode())
641+
);
642+
Err(Error::WalletOperationTimeout)
643+
},
644+
}
645+
}
646+
647+
pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
648+
let timeout_fut = tokio::time::timeout(
649+
Duration::from_secs(DEFAULT_TX_LOOKUP_TIMEOUT_SECS),
650+
self.api_client.get_raw_transaction(txid),
651+
);
652+
653+
match timeout_fut.await {
654+
Ok(res) => res.map_err(|e| {
655+
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
656+
Error::TxLookupFailed
657+
}),
658+
Err(e) => {
659+
log_error!(self.logger, "Failed to get transaction {} due to timeout: {}", txid, e);
660+
Err(Error::TxLookupTimeout)
661+
},
662+
}
663+
}
613664
}
614665

615666
#[derive(Clone)]
@@ -1169,6 +1220,34 @@ impl BitcoindClient {
11691220
.collect();
11701221
Ok(evicted_txids)
11711222
}
1223+
1224+
/// Tests whether the provided transaction would be accepted by the mempool.
1225+
pub(crate) async fn test_mempool_accept(
1226+
&self, tx: &Transaction,
1227+
) -> Result<bool, RpcClientError> {
1228+
match self {
1229+
BitcoindClient::Rpc { rpc_client, .. } => {
1230+
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
1231+
},
1232+
BitcoindClient::Rest { rpc_client, .. } => {
1233+
// We rely on the internal RPC client to make this call, as this
1234+
// operation is not supported by Bitcoin Core's REST interface.
1235+
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
1236+
},
1237+
}
1238+
}
1239+
1240+
async fn test_mempool_accept_inner(
1241+
rpc_client: Arc<RpcClient>, tx: &Transaction,
1242+
) -> Result<bool, RpcClientError> {
1243+
let tx_serialized = bitcoin::consensus::encode::serialize_hex(tx);
1244+
let tx_array = serde_json::json!([tx_serialized]);
1245+
1246+
rpc_client
1247+
.call_method::<TestMempoolAcceptResponse>("testmempoolaccept", &[tx_array])
1248+
.await
1249+
.map(|resp| resp.0)
1250+
}
11721251
}
11731252

11741253
impl BlockSource for BitcoindClient {
@@ -1324,6 +1403,23 @@ impl TryInto<GetMempoolEntryResponse> for JsonResponse {
13241403
}
13251404
}
13261405

1406+
pub(crate) struct TestMempoolAcceptResponse(pub bool);
1407+
1408+
impl TryInto<TestMempoolAcceptResponse> for JsonResponse {
1409+
type Error = String;
1410+
fn try_into(self) -> Result<TestMempoolAcceptResponse, String> {
1411+
let array =
1412+
self.0.as_array().ok_or("Failed to parse testmempoolaccept response".to_string())?;
1413+
let first =
1414+
array.first().ok_or("Empty array response from testmempoolaccept".to_string())?;
1415+
let allowed = first
1416+
.get("allowed")
1417+
.and_then(|v| v.as_bool())
1418+
.ok_or("Missing 'allowed' field in testmempoolaccept response".to_string())?;
1419+
Ok(TestMempoolAcceptResponse(allowed))
1420+
}
1421+
}
1422+
13271423
#[derive(Debug, Clone)]
13281424
pub(crate) struct MempoolEntry {
13291425
/// The transaction id

0 commit comments

Comments
 (0)