Skip to content

Commit 0411ada

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 fae2746 commit 0411ada

File tree

20 files changed

+1820
-16
lines changed

20 files changed

+1820
-16
lines changed

Cargo.toml

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

84+
payjoin = { git = "https://github.com/payjoin/rust-payjoin.git", package = "payjoin", default-features = false, features = ["v2", "io"] }
85+
8486
[target.'cfg(windows)'.dependencies]
8587
winapi = { version = "0.3", features = ["winbase"] }
8688

bindings/ldk_node.udl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ interface Node {
9393
SpontaneousPayment spontaneous_payment();
9494
OnchainPayment onchain_payment();
9595
UnifiedPayment unified_payment();
96+
PayjoinPayment payjoin_payment();
9697
LSPS1Liquidity lsps1_liquidity();
9798
[Throws=NodeError]
9899
void lnurl_auth(string lnurl);
@@ -157,6 +158,8 @@ interface FeeRate {
157158

158159
typedef interface UnifiedPayment;
159160

161+
typedef interface PayjoinPayment;
162+
160163
typedef interface LSPS1Liquidity;
161164

162165
[Error]
@@ -221,6 +224,9 @@ enum NodeError {
221224
"LnurlAuthFailed",
222225
"LnurlAuthTimeout",
223226
"InvalidLnurl",
227+
"PayjoinNotConfigured",
228+
"PayjoinSessionCreationFailed",
229+
"PayjoinSessionFailed"
224230
};
225231

226232
typedef dictionary NodeStatus;

src/builder.rs

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ use vss_client::headers::VssHeaderProvider;
4545
use crate::chain::ChainSource;
4646
use crate::config::{
4747
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
48-
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig,
48+
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, PayjoinConfig,
4949
DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
5050
};
5151
use crate::connection::ConnectionManager;
@@ -56,12 +56,13 @@ use crate::gossip::GossipSource;
5656
use crate::io::sqlite_store::SqliteStore;
5757
use crate::io::utils::{
5858
read_event_queue, read_external_pathfinding_scores_from_cache, read_network_graph,
59-
read_node_metrics, read_output_sweeper, read_payments, read_peer_info, read_pending_payments,
60-
read_scorer, write_node_metrics,
59+
read_node_metrics, read_output_sweeper, read_payjoin_sessions, read_payments, read_peer_info,
60+
read_pending_payments, read_scorer, write_node_metrics,
6161
};
6262
use crate::io::vss_store::VssStoreBuilder;
6363
use crate::io::{
64-
self, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
64+
self, PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE, PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
65+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
6566
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
6667
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
6768
};
@@ -72,13 +73,14 @@ use crate::lnurl_auth::LnurlAuth;
7273
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
7374
use crate::message_handler::NodeCustomMessageHandler;
7475
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
76+
use crate::payment::payjoin::manager::PayjoinManager;
7577
use crate::peer_store::PeerStore;
7678
use crate::runtime::{Runtime, RuntimeSpawner};
7779
use crate::tx_broadcaster::TransactionBroadcaster;
7880
use crate::types::{
7981
AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreWrapper, GossipSync, Graph,
80-
KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager, PendingPaymentStore,
81-
Persister, SyncAndAsyncKVStore,
82+
KeysManager, MessageRouter, OnionMessenger, PayjoinSessionStore, PaymentStore, PeerManager,
83+
PendingPaymentStore, Persister, SyncAndAsyncKVStore,
8284
};
8385
use crate::wallet::persist::KVStoreWalletPersister;
8486
use crate::wallet::Wallet;
@@ -549,6 +551,15 @@ impl NodeBuilder {
549551
Ok(self)
550552
}
551553

554+
/// Configures the [`Node`] instance to enable payjoin payments.
555+
///
556+
/// The `payjoin_config` specifies the PayJoin directory and OHTTP relay URLs required
557+
/// for payjoin V2 protocol.
558+
pub fn set_payjoin_config(&mut self, payjoin_config: PayjoinConfig) -> &mut Self {
559+
self.config.payjoin_config = Some(payjoin_config);
560+
self
561+
}
562+
552563
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
553564
/// historical wallet funds.
554565
///
@@ -972,6 +983,14 @@ impl ArcedNodeBuilder {
972983
self.inner.write().unwrap().set_async_payments_role(role).map(|_| ())
973984
}
974985

986+
/// Configures the [`Node`] instance to enable payjoin payments.
987+
///
988+
/// The `payjoin_config` specifies the PayJoin directory and OHTTP relay URLs required
989+
/// for payjoin V2 protocol.
990+
pub fn set_payjoin_config(&self, payjoin_config: PayjoinConfig) {
991+
self.inner.write().unwrap().set_payjoin_config(payjoin_config);
992+
}
993+
975994
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
976995
/// historical wallet funds.
977996
///
@@ -1151,12 +1170,13 @@ fn build_with_store_internal(
11511170

11521171
let kv_store_ref = Arc::clone(&kv_store);
11531172
let logger_ref = Arc::clone(&logger);
1154-
let (payment_store_res, node_metris_res, pending_payment_store_res) =
1173+
let (payment_store_res, node_metris_res, pending_payment_store_res, payjoin_session_store_res) =
11551174
runtime.block_on(async move {
11561175
tokio::join!(
11571176
read_payments(&*kv_store_ref, Arc::clone(&logger_ref)),
11581177
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
1159-
read_pending_payments(&*kv_store_ref, Arc::clone(&logger_ref))
1178+
read_pending_payments(&*kv_store_ref, Arc::clone(&logger_ref)),
1179+
read_payjoin_sessions(&*kv_store_ref, Arc::clone(&logger_ref))
11601180
)
11611181
});
11621182

@@ -1841,6 +1861,34 @@ fn build_with_store_internal(
18411861

18421862
let pathfinding_scores_sync_url = pathfinding_scores_sync_config.map(|c| c.url.clone());
18431863

1864+
let payjoin_session_store = match payjoin_session_store_res {
1865+
Ok(payjoin_sessions) => Arc::new(PayjoinSessionStore::new(
1866+
payjoin_sessions,
1867+
PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE.to_string(),
1868+
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE.to_string(),
1869+
Arc::clone(&kv_store),
1870+
Arc::clone(&logger),
1871+
)),
1872+
Err(e) => {
1873+
log_error!(logger, "Failed to read payjoin session data from store: {}", e);
1874+
return Err(BuildError::ReadFailed);
1875+
},
1876+
};
1877+
1878+
let payjoin_manager = Arc::new(PayjoinManager::new(
1879+
Arc::clone(&payjoin_session_store),
1880+
Arc::clone(&logger),
1881+
Arc::clone(&config),
1882+
Arc::clone(&wallet),
1883+
Arc::clone(&fee_estimator),
1884+
Arc::clone(&chain_source),
1885+
Arc::clone(&channel_manager),
1886+
stop_sender.subscribe(),
1887+
Arc::clone(&payment_store),
1888+
Arc::clone(&pending_payment_store),
1889+
Arc::clone(&tx_broadcaster),
1890+
));
1891+
18441892
#[cfg(cycle_tests)]
18451893
let mut _leak_checker = crate::LeakChecker(Vec::new());
18461894
#[cfg(cycle_tests)]
@@ -1888,6 +1936,7 @@ fn build_with_store_internal(
18881936
hrn_resolver,
18891937
#[cfg(cycle_tests)]
18901938
_leak_checker,
1939+
payjoin_manager,
18911940
})
18921941
}
18931942

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,
@@ -620,6 +620,57 @@ impl BitcoindChainSource {
620620
}
621621
}
622622
}
623+
624+
pub(crate) async fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> {
625+
let timeout_fut = tokio::time::timeout(
626+
Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS),
627+
self.api_client.test_mempool_accept(tx),
628+
);
629+
630+
match timeout_fut.await {
631+
Ok(res) => res.map_err(|e| {
632+
log_error!(
633+
self.logger,
634+
"Failed to test mempool accept for transaction {}: {}",
635+
tx.compute_txid(),
636+
e
637+
);
638+
Error::WalletOperationFailed
639+
}),
640+
Err(e) => {
641+
log_error!(
642+
self.logger,
643+
"Failed to test mempool accept for transaction {} due to timeout: {}",
644+
tx.compute_txid(),
645+
e
646+
);
647+
log_trace!(
648+
self.logger,
649+
"Failed test mempool accept transaction bytes: {}",
650+
log_bytes!(tx.encode())
651+
);
652+
Err(Error::WalletOperationTimeout)
653+
},
654+
}
655+
}
656+
657+
pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
658+
let timeout_fut = tokio::time::timeout(
659+
Duration::from_secs(DEFAULT_TX_LOOKUP_TIMEOUT_SECS),
660+
self.api_client.get_raw_transaction(txid),
661+
);
662+
663+
match timeout_fut.await {
664+
Ok(res) => res.map_err(|e| {
665+
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
666+
Error::TxSyncFailed
667+
}),
668+
Err(e) => {
669+
log_error!(self.logger, "Failed to get transaction {} due to timeout: {}", txid, e);
670+
Err(Error::TxSyncTimeout)
671+
},
672+
}
673+
}
623674
}
624675

625676
#[derive(Clone)]
@@ -1179,6 +1230,34 @@ impl BitcoindClient {
11791230
.collect();
11801231
Ok(evicted_txids)
11811232
}
1233+
1234+
/// Tests whether the provided transaction would be accepted by the mempool.
1235+
pub(crate) async fn test_mempool_accept(
1236+
&self, tx: &Transaction,
1237+
) -> Result<bool, RpcClientError> {
1238+
match self {
1239+
BitcoindClient::Rpc { rpc_client, .. } => {
1240+
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
1241+
},
1242+
BitcoindClient::Rest { rpc_client, .. } => {
1243+
// We rely on the internal RPC client to make this call, as this
1244+
// operation is not supported by Bitcoin Core's REST interface.
1245+
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
1246+
},
1247+
}
1248+
}
1249+
1250+
async fn test_mempool_accept_inner(
1251+
rpc_client: Arc<RpcClient>, tx: &Transaction,
1252+
) -> Result<bool, RpcClientError> {
1253+
let tx_serialized = bitcoin::consensus::encode::serialize_hex(tx);
1254+
let tx_array = serde_json::json!([tx_serialized]);
1255+
1256+
rpc_client
1257+
.call_method::<TestMempoolAcceptResponse>("testmempoolaccept", &[tx_array])
1258+
.await
1259+
.map(|resp| resp.0)
1260+
}
11821261
}
11831262

11841263
impl BlockSource for BitcoindClient {
@@ -1334,6 +1413,23 @@ impl TryInto<GetMempoolEntryResponse> for JsonResponse {
13341413
}
13351414
}
13361415

1416+
pub(crate) struct TestMempoolAcceptResponse(pub bool);
1417+
1418+
impl TryInto<TestMempoolAcceptResponse> for JsonResponse {
1419+
type Error = String;
1420+
fn try_into(self) -> Result<TestMempoolAcceptResponse, String> {
1421+
let array =
1422+
self.0.as_array().ok_or("Failed to parse testmempoolaccept response".to_string())?;
1423+
let first =
1424+
array.first().ok_or("Empty array response from testmempoolaccept".to_string())?;
1425+
let allowed = first
1426+
.get("allowed")
1427+
.and_then(|v| v.as_bool())
1428+
.ok_or("Missing 'allowed' field in testmempoolaccept response".to_string())?;
1429+
Ok(TestMempoolAcceptResponse(allowed))
1430+
}
1431+
}
1432+
13371433
#[derive(Debug, Clone)]
13381434
pub(crate) struct MempoolEntry {
13391435
/// The transaction id

src/chain/electrum.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,21 @@ impl ElectrumChainSource {
288288
electrum_client.broadcast(tx).await;
289289
}
290290
}
291+
292+
pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
293+
let electrum_client: Arc<ElectrumRuntimeClient> =
294+
if let Some(client) = self.electrum_runtime_status.read().unwrap().client().as_ref() {
295+
Arc::clone(client)
296+
} else {
297+
debug_assert!(
298+
false,
299+
"We should have started the chain source before getting transactions"
300+
);
301+
return Err(Error::TxSyncFailed);
302+
};
303+
304+
electrum_client.get_transaction(txid).await
305+
}
291306
}
292307

293308
impl Filter for ElectrumChainSource {
@@ -652,6 +667,48 @@ impl ElectrumRuntimeClient {
652667

653668
Ok(new_fee_rate_cache)
654669
}
670+
671+
async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
672+
let electrum_client = Arc::clone(&self.electrum_client);
673+
let txid_copy = *txid;
674+
675+
let spawn_fut =
676+
self.runtime.spawn_blocking(move || electrum_client.transaction_get(&txid_copy));
677+
let timeout_fut = tokio::time::timeout(
678+
Duration::from_secs(
679+
self.sync_config.timeouts_config.lightning_wallet_sync_timeout_secs,
680+
),
681+
spawn_fut,
682+
);
683+
684+
match timeout_fut.await {
685+
Ok(res) => match res {
686+
Ok(inner_res) => match inner_res {
687+
Ok(tx) => Ok(Some(tx)),
688+
Err(e) => {
689+
// Check if it's a "not found" error
690+
let error_str = e.to_string();
691+
if error_str.contains("No such mempool or blockchain transaction")
692+
|| error_str.contains("not found")
693+
{
694+
Ok(None)
695+
} else {
696+
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
697+
Err(Error::TxSyncFailed)
698+
}
699+
},
700+
},
701+
Err(e) => {
702+
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
703+
Err(Error::TxSyncFailed)
704+
},
705+
},
706+
Err(e) => {
707+
log_error!(self.logger, "Failed to get transaction {} due to timeout: {}", txid, e);
708+
Err(Error::TxSyncTimeout)
709+
},
710+
}
711+
}
655712
}
656713

657714
impl Filter for ElectrumRuntimeClient {

src/chain/esplora.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,13 @@ impl EsploraChainSource {
422422
}
423423
}
424424
}
425+
426+
pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
427+
self.esplora_client.get_tx(txid).await.map_err(|e| {
428+
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
429+
Error::TxSyncFailed
430+
})
431+
}
425432
}
426433

427434
impl Filter for EsploraChainSource {

0 commit comments

Comments
 (0)