Skip to content

Commit 33e6724

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 08efb3a commit 33e6724

19 files changed

Lines changed: 1864 additions & 16 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ prost = { version = "0.11.6", default-features = false}
8787
#bitcoin-payment-instructions = { version = "0.6" }
8888
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "0e430be98c09540624a68a68022ee0551e86d1be" }
8989

90+
payjoin = { version = "0.25.0", default-features = false, features = ["v2", "io"] }
91+
9092
[target.'cfg(windows)'.dependencies]
9193
winapi = { version = "0.3", features = ["winbase"] }
9294

bindings/ldk_node.udl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ interface Node {
112112
SpontaneousPayment spontaneous_payment();
113113
OnchainPayment onchain_payment();
114114
UnifiedPayment unified_payment();
115+
[Throws=NodeError]
116+
PayjoinPayment payjoin_payment();
115117
Liquidity liquidity();
116118
[Throws=NodeError]
117119
void lnurl_auth(string lnurl);
@@ -182,6 +184,8 @@ interface FeeRate {
182184

183185
typedef interface UnifiedPayment;
184186

187+
typedef interface PayjoinPayment;
188+
185189
typedef interface Liquidity;
186190

187191
[Error]
@@ -209,6 +213,8 @@ enum NodeError {
209213
"OnchainTxSigningFailed",
210214
"TxSyncFailed",
211215
"TxSyncTimeout",
216+
"TxLookupFailed",
217+
"TxLookupTimeout",
212218
"GossipUpdateFailed",
213219
"GossipUpdateTimeout",
214220
"LiquidityRequestFailed",
@@ -247,6 +253,9 @@ enum NodeError {
247253
"LnurlAuthTimeout",
248254
"InvalidLnurl",
249255
"ChainSourceNotSupported",
256+
"PayjoinNotConfigured",
257+
"PayjoinSessionCreationFailed",
258+
"PayjoinSessionFailed",
250259
};
251260

252261
typedef dictionary NodeStatus;

src/builder.rs

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ use crate::chain::ChainSource;
4949
use crate::config::{
5050
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
5151
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
52-
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
52+
PayjoinConfig, TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
5353
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
5454
};
5555
use crate::connection::ConnectionManager;
@@ -65,7 +65,8 @@ use crate::io::utils::{
6565
};
6666
use crate::io::vss_store::VssStoreBuilder;
6767
use crate::io::{
68-
self, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
68+
self, PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE, PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
69+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
6970
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
7071
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
7172
};
@@ -74,6 +75,7 @@ use crate::lnurl_auth::LnurlAuth;
7475
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
7576
use crate::message_handler::NodeCustomMessageHandler;
7677
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
78+
use crate::payment::payjoin::manager::PayjoinManager;
7779
use crate::peer_store::PeerStore;
7880
use crate::probing::{
7981
HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind,
@@ -83,8 +85,8 @@ use crate::runtime::{Runtime, RuntimeSpawner};
8385
use crate::tx_broadcaster::TransactionBroadcaster;
8486
use crate::types::{
8587
AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper,
86-
GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger, PaymentStore,
87-
PeerManager, PendingPaymentStore,
88+
GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger,
89+
PayjoinSessionStore, PaymentStore, PeerManager, PendingPaymentStore,
8890
};
8991
use crate::wallet::persist::KVStoreWalletPersister;
9092
use crate::wallet::Wallet;
@@ -211,6 +213,8 @@ pub enum BuildError {
211213
ChainTipFetchFailed,
212214
/// The configured wallet rescan height is above the current chain tip.
213215
WalletRescanHeightTooHigh,
216+
/// The payjoin configuration requires a Bitcoin Core backend, but a different chain source was configured.
217+
PayjoinConfigMismatch,
214218
}
215219

216220
impl fmt::Display for BuildError {
@@ -257,6 +261,9 @@ impl fmt::Display for BuildError {
257261
Self::WalletRescanHeightTooHigh => {
258262
write!(f, "Wallet rescan height is above the current chain tip.")
259263
},
264+
Self::PayjoinConfigMismatch => {
265+
write!(f, "Payjoin requires a Bitcoin Core chain source, but a different one was configured.")
266+
},
260267
}
261268
}
262269
}
@@ -637,6 +644,15 @@ impl NodeBuilder {
637644
Ok(self)
638645
}
639646

647+
/// Configures the [`Node`] instance to enable payjoin payments.
648+
///
649+
/// The `payjoin_config` specifies the PayJoin directory and OHTTP relay URLs required
650+
/// for payjoin V2 protocol.
651+
pub fn set_payjoin_config(&mut self, payjoin_config: PayjoinConfig) -> &mut Self {
652+
self.config.payjoin_config = Some(payjoin_config);
653+
self
654+
}
655+
640656
/// Sets background probing config.
641657
///
642658
/// Use [`ProbingConfigBuilder`] to build the configuration:
@@ -1199,6 +1215,14 @@ impl ArcedNodeBuilder {
11991215
self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ())
12001216
}
12011217

1218+
/// Configures the [`Node`] instance to enable payjoin payments.
1219+
///
1220+
/// The `payjoin_config` specifies the PayJoin directory and OHTTP relay URLs required
1221+
/// for payjoin V2 protocol.
1222+
pub fn set_payjoin_config(&self, payjoin_config: PayjoinConfig) {
1223+
self.inner.write().expect("lock").set_payjoin_config(payjoin_config);
1224+
}
1225+
12021226
/// Configures background probing.
12031227
///
12041228
/// Use [`ProbingConfigBuilder`] to build the configuration.
@@ -1439,7 +1463,7 @@ fn build_with_store_internal(
14391463

14401464
let kv_store_ref = Arc::clone(&kv_store);
14411465
let logger_ref = Arc::clone(&logger);
1442-
let (payment_store_res, node_metris_res, pending_payment_store_res) =
1466+
let (payment_store_res, node_metris_res, pending_payment_store_res, payjoin_session_store_res) =
14431467
runtime.block_on(async move {
14441468
tokio::join!(
14451469
read_all_objects(
@@ -1454,6 +1478,12 @@ fn build_with_store_internal(
14541478
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
14551479
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
14561480
Arc::clone(&logger_ref),
1481+
),
1482+
read_all_objects(
1483+
&*kv_store_ref,
1484+
PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE,
1485+
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
1486+
Arc::clone(&logger_ref),
14571487
)
14581488
)
14591489
});
@@ -2249,6 +2279,42 @@ fn build_with_store_internal(
22492279

22502280
let pathfinding_scores_sync_url = pathfinding_scores_sync_config.map(|c| c.url.clone());
22512281

2282+
let payjoin_manager = if config.payjoin_config.is_some() {
2283+
if !matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })) {
2284+
return Err(BuildError::PayjoinConfigMismatch);
2285+
}
2286+
2287+
let payjoin_session_store = match payjoin_session_store_res {
2288+
Ok(payjoin_sessions) => Arc::new(PayjoinSessionStore::new(
2289+
payjoin_sessions,
2290+
PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE.to_string(),
2291+
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE.to_string(),
2292+
Arc::clone(&kv_store),
2293+
Arc::clone(&logger),
2294+
)),
2295+
Err(e) => {
2296+
log_error!(logger, "Failed to read payjoin session data from store: {}", e);
2297+
return Err(BuildError::ReadFailed);
2298+
},
2299+
};
2300+
2301+
Some(Arc::new(PayjoinManager::new(
2302+
Arc::clone(&payjoin_session_store),
2303+
Arc::clone(&logger),
2304+
Arc::clone(&config),
2305+
Arc::clone(&wallet),
2306+
Arc::clone(&fee_estimator),
2307+
Arc::clone(&chain_source),
2308+
Arc::clone(&channel_manager),
2309+
stop_sender.subscribe(),
2310+
Arc::clone(&payment_store),
2311+
Arc::clone(&pending_payment_store),
2312+
Arc::clone(&tx_broadcaster),
2313+
)))
2314+
} else {
2315+
None
2316+
};
2317+
22522318
#[cfg(cycle_tests)]
22532319
let mut _leak_checker = crate::LeakChecker(Vec::new());
22542320
#[cfg(cycle_tests)]
@@ -2342,6 +2408,7 @@ fn build_with_store_internal(
23422408
prober,
23432409
#[cfg(cycle_tests)]
23442410
_leak_checker,
2411+
payjoin_manager,
23452412
})
23462413
}
23472414

src/chain/bitcoind.rs

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ use serde::Serialize;
3434
use super::WalletSyncStatus;
3535
use crate::config::{
3636
BitcoindRestClientConfig, Config, DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS,
37-
DEFAULT_TX_BROADCAST_TIMEOUT_SECS,
37+
DEFAULT_TX_BROADCAST_TIMEOUT_SECS, DEFAULT_TX_LOOKUP_TIMEOUT_SECS,
3838
};
3939
use crate::fee_estimator::{
4040
apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target,
@@ -653,6 +653,57 @@ impl BitcoindChainSource {
653653
},
654654
}
655655
}
656+
657+
pub(crate) async fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> {
658+
let timeout_fut = tokio::time::timeout(
659+
Duration::from_secs(DEFAULT_TX_LOOKUP_TIMEOUT_SECS),
660+
self.api_client.test_mempool_accept(tx),
661+
);
662+
663+
match timeout_fut.await {
664+
Ok(res) => res.map_err(|e| {
665+
log_error!(
666+
self.logger,
667+
"Failed to test mempool accept for transaction {}: {}",
668+
tx.compute_txid(),
669+
e
670+
);
671+
Error::WalletOperationFailed
672+
}),
673+
Err(e) => {
674+
log_error!(
675+
self.logger,
676+
"Failed to test mempool accept for transaction {} due to timeout: {}",
677+
tx.compute_txid(),
678+
e
679+
);
680+
log_trace!(
681+
self.logger,
682+
"Failed test mempool accept transaction bytes: {}",
683+
log_bytes!(tx.encode())
684+
);
685+
Err(Error::WalletOperationTimeout)
686+
},
687+
}
688+
}
689+
690+
pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
691+
let timeout_fut = tokio::time::timeout(
692+
Duration::from_secs(DEFAULT_TX_LOOKUP_TIMEOUT_SECS),
693+
self.api_client.get_raw_transaction(txid),
694+
);
695+
696+
match timeout_fut.await {
697+
Ok(res) => res.map_err(|e| {
698+
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
699+
Error::TxLookupFailed
700+
}),
701+
Err(e) => {
702+
log_error!(self.logger, "Failed to get transaction {} due to timeout: {}", txid, e);
703+
Err(Error::TxLookupTimeout)
704+
},
705+
}
706+
}
656707
}
657708

658709
#[derive(Clone)]
@@ -1269,6 +1320,34 @@ impl BitcoindClient {
12691320
.collect();
12701321
Ok(evicted_txids)
12711322
}
1323+
1324+
/// Tests whether the provided transaction would be accepted by the mempool.
1325+
pub(crate) async fn test_mempool_accept(
1326+
&self, tx: &Transaction,
1327+
) -> Result<bool, RpcClientError> {
1328+
match self {
1329+
BitcoindClient::Rpc { rpc_client, .. } => {
1330+
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
1331+
},
1332+
BitcoindClient::Rest { rpc_client, .. } => {
1333+
// We rely on the internal RPC client to make this call, as this
1334+
// operation is not supported by Bitcoin Core's REST interface.
1335+
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
1336+
},
1337+
}
1338+
}
1339+
1340+
async fn test_mempool_accept_inner(
1341+
rpc_client: Arc<RpcClient>, tx: &Transaction,
1342+
) -> Result<bool, RpcClientError> {
1343+
let tx_serialized = bitcoin::consensus::encode::serialize_hex(tx);
1344+
let tx_array = serde_json::json!([tx_serialized]);
1345+
1346+
rpc_client
1347+
.call_method::<TestMempoolAcceptResponse>("testmempoolaccept", &[tx_array])
1348+
.await
1349+
.map(|resp| resp.0)
1350+
}
12721351
}
12731352

12741353
impl BlockSource for BitcoindClient {
@@ -1441,6 +1520,23 @@ impl TryInto<SubmitPackageResponse> for JsonResponse {
14411520
}
14421521
}
14431522

1523+
pub(crate) struct TestMempoolAcceptResponse(pub bool);
1524+
1525+
impl TryInto<TestMempoolAcceptResponse> for JsonResponse {
1526+
type Error = String;
1527+
fn try_into(self) -> Result<TestMempoolAcceptResponse, String> {
1528+
let array =
1529+
self.0.as_array().ok_or("Failed to parse testmempoolaccept response".to_string())?;
1530+
let first =
1531+
array.first().ok_or("Empty array response from testmempoolaccept".to_string())?;
1532+
let allowed = first
1533+
.get("allowed")
1534+
.and_then(|v| v.as_bool())
1535+
.ok_or("Missing 'allowed' field in testmempoolaccept response".to_string())?;
1536+
Ok(TestMempoolAcceptResponse(allowed))
1537+
}
1538+
}
1539+
14441540
#[derive(Debug, Clone)]
14451541
pub(crate) struct MempoolEntry {
14461542
/// The transaction id

0 commit comments

Comments
 (0)