Skip to content

Commit 0f252b7

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 13ac606 commit 0f252b7

19 files changed

Lines changed: 1819 additions & 33 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ prost = { version = "0.11.6", default-features = false, optional = true}
134134
#bitcoin-payment-instructions = { version = "0.6" }
135135
bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "c359b125e972ff49b5c2e9f6865afb11500286a0", optional = true }
136136

137+
payjoin = { version = "1.0.0", default-features = false, features = ["v2", "io"] }
138+
137139
[target.'cfg(windows)'.dependencies]
138140
winapi = { version = "0.3", features = ["winbase"] }
139141

bindings/ldk_node.udl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ interface Node {
6767
Bolt12Payment bolt12_payment();
6868
SpontaneousPayment spontaneous_payment();
6969
OnchainPayment onchain_payment();
70+
[Throws=NodeError]
71+
PayjoinPayment payjoin_payment();
7072
Liquidity liquidity();
7173
[Throws=NodeError]
7274
void lnurl_auth(string lnurl);
@@ -137,6 +139,8 @@ interface FeeRate {
137139
u64 to_sat_per_vb_ceil();
138140
};
139141

142+
typedef interface PayjoinPayment;
143+
140144
typedef interface Liquidity;
141145

142146
[Error]
@@ -165,6 +169,8 @@ enum NodeError {
165169
"OnchainTxSigningFailed",
166170
"TxSyncFailed",
167171
"TxSyncTimeout",
172+
"TxLookupFailed",
173+
"TxLookupTimeout",
168174
"GossipUpdateFailed",
169175
"GossipUpdateTimeout",
170176
"LiquidityRequestFailed",
@@ -206,6 +212,9 @@ enum NodeError {
206212
"InvalidLnurl",
207213
"ChainSourceNotSupported",
208214
"InvalidPayerProof",
215+
"PayjoinNotConfigured",
216+
"PayjoinSessionCreationFailed",
217+
"PayjoinSessionFailed",
209218
};
210219

211220
typedef dictionary NodeStatus;

src/builder.rs

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ use crate::chain::ChainSource;
5757
use crate::config::BitcoindRestClientConfig;
5858
use crate::config::{
5959
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, Config,
60-
ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, TorConfig,
60+
ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, PayjoinConfig, TorConfig,
6161
DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
6262
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
6363
PAYMENT_CACHE_WARMUP_COUNT,
@@ -82,7 +82,8 @@ use crate::io::utils::{
8282
#[cfg(feature = "storage-vss")]
8383
use crate::io::vss_store::VssStoreBuilder;
8484
use crate::io::{
85-
self, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
85+
self, PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE, PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
86+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
8687
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
8788
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
8889
};
@@ -91,6 +92,7 @@ use crate::lnurl_auth::LnurlAuth;
9192
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
9293
use crate::message_handler::NodeCustomMessageHandler;
9394
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
95+
use crate::payment::payjoin::manager::PayjoinManager;
9496
#[cfg(feature = "unified-payments")]
9597
use crate::payment::HRNResolver;
9698
use crate::peer_store::PeerStore;
@@ -102,8 +104,8 @@ use crate::runtime::{Runtime, RuntimeSpawner};
102104
use crate::tx_broadcaster::TransactionBroadcaster;
103105
use crate::types::{
104106
AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper,
105-
GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager,
106-
PendingPaymentStore,
107+
GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PayjoinSessionStore,
108+
PaymentStore, PeerManager, PendingPaymentStore,
107109
};
108110
use crate::wallet::persist::{read_address_pool, KVStoreWalletPersister};
109111
use crate::wallet::Wallet;
@@ -230,6 +232,8 @@ pub enum BuildError {
230232
ChainTipFetchFailed,
231233
/// The configured wallet rescan height is above the current chain tip.
232234
WalletRescanHeightTooHigh,
235+
/// The payjoin configuration requires a Bitcoin Core backend, but a different chain source was configured.
236+
PayjoinConfigMismatch,
233237
}
234238

235239
impl fmt::Display for BuildError {
@@ -276,6 +280,9 @@ impl fmt::Display for BuildError {
276280
Self::WalletRescanHeightTooHigh => {
277281
write!(f, "Wallet rescan height is above the current chain tip.")
278282
},
283+
Self::PayjoinConfigMismatch => {
284+
write!(f, "Payjoin requires a Bitcoin Core chain source, but a different one was configured.")
285+
},
279286
}
280287
}
281288
}
@@ -664,6 +671,15 @@ impl NodeBuilder {
664671
Ok(self)
665672
}
666673

674+
/// Configures the [`Node`] instance to enable payjoin payments.
675+
///
676+
/// The `payjoin_config` specifies the PayJoin directory and OHTTP relay URLs required
677+
/// for payjoin V2 protocol.
678+
pub fn set_payjoin_config(&mut self, payjoin_config: PayjoinConfig) -> &mut Self {
679+
self.config.payjoin_config = Some(payjoin_config);
680+
self
681+
}
682+
667683
/// Sets background probing config.
668684
///
669685
/// Use [`ProbingConfigBuilder`] to build the configuration:
@@ -1272,6 +1288,14 @@ impl Builder {
12721288
self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ())
12731289
}
12741290

1291+
/// Configures the [`Node`] instance to enable payjoin payments.
1292+
///
1293+
/// The `payjoin_config` specifies the PayJoin directory and OHTTP relay URLs required
1294+
/// for payjoin V2 protocol.
1295+
pub fn set_payjoin_config(&self, payjoin_config: PayjoinConfig) {
1296+
self.inner.write().expect("lock").set_payjoin_config(payjoin_config);
1297+
}
1298+
12751299
/// Configures background probing.
12761300
///
12771301
/// Use [`ProbingConfigBuilder`] to build the configuration.
@@ -1526,26 +1550,37 @@ fn build_with_store_internal(
15261550

15271551
let kv_store_ref = Arc::clone(&kv_store);
15281552
let logger_ref = Arc::clone(&logger);
1529-
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
1530-
.block_on(async move {
1531-
tokio::join!(
1532-
read_n_objects(
1533-
&*kv_store_ref,
1534-
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1535-
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1536-
PAYMENT_CACHE_WARMUP_COUNT,
1537-
Arc::clone(&logger_ref),
1538-
),
1539-
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
1540-
read_all_objects(
1541-
&*kv_store_ref,
1542-
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1543-
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1544-
Arc::clone(&logger_ref),
1545-
),
1546-
read_address_pool(&*kv_store_ref, &*logger_ref)
1547-
)
1548-
});
1553+
let (
1554+
payment_store_res,
1555+
node_metris_res,
1556+
pending_payment_store_res,
1557+
address_pool_res,
1558+
payjoin_session_store_res,
1559+
) = runtime.block_on(async move {
1560+
tokio::join!(
1561+
read_n_objects(
1562+
&*kv_store_ref,
1563+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1564+
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1565+
PAYMENT_CACHE_WARMUP_COUNT,
1566+
Arc::clone(&logger_ref),
1567+
),
1568+
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
1569+
read_all_objects(
1570+
&*kv_store_ref,
1571+
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1572+
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1573+
Arc::clone(&logger_ref),
1574+
),
1575+
read_address_pool(&*kv_store_ref, &*logger_ref),
1576+
read_all_objects(
1577+
&*kv_store_ref,
1578+
PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE,
1579+
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
1580+
Arc::clone(&logger_ref),
1581+
),
1582+
)
1583+
});
15491584

15501585
// Initialize the status fields.
15511586
let node_metrics = match node_metris_res {
@@ -2408,6 +2443,43 @@ fn build_with_store_internal(
24082443

24092444
let pathfinding_scores_sync_url = pathfinding_scores_sync_config.map(|c| c.url.clone());
24102445

2446+
let payjoin_manager = if config.payjoin_config.is_some() {
2447+
if !matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })) {
2448+
return Err(BuildError::PayjoinConfigMismatch);
2449+
}
2450+
2451+
let payjoin_session_store = match payjoin_session_store_res {
2452+
Ok(payjoin_sessions) => Arc::new(PayjoinSessionStore::new(
2453+
payjoin_sessions,
2454+
KeepAllEntries,
2455+
PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE.to_string(),
2456+
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE.to_string(),
2457+
Arc::clone(&kv_store),
2458+
Arc::clone(&logger),
2459+
)),
2460+
Err(e) => {
2461+
log_error!(logger, "Failed to read payjoin session data from store: {}", e);
2462+
return Err(BuildError::ReadFailed);
2463+
},
2464+
};
2465+
2466+
Some(Arc::new(PayjoinManager::new(
2467+
Arc::clone(&payjoin_session_store),
2468+
Arc::clone(&logger),
2469+
Arc::clone(&config),
2470+
Arc::clone(&wallet),
2471+
Arc::clone(&fee_estimator),
2472+
Arc::clone(&chain_source),
2473+
Arc::clone(&channel_manager),
2474+
stop_sender.subscribe(),
2475+
Arc::clone(&payment_store),
2476+
Arc::clone(&pending_payment_store),
2477+
Arc::clone(&tx_broadcaster),
2478+
)))
2479+
} else {
2480+
None
2481+
};
2482+
24112483
let prober = probing_config.map(|probing_cfg| {
24122484
let strategy: Arc<dyn ProbingStrategy> = match &probing_cfg.kind {
24132485
ProbingStrategyKind::HighDegree { top_node_count } => {
@@ -2502,6 +2574,7 @@ fn build_with_store_internal(
25022574
prober,
25032575
#[cfg(cycle_tests)]
25042576
_leak_checker,
2577+
payjoin_manager,
25052578
})
25062579
}
25072580

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::{WalletSyncGuard, 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,
@@ -719,6 +719,57 @@ impl BitcoindChainSource {
719719
},
720720
}
721721
}
722+
723+
pub(crate) async fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> {
724+
let timeout_fut = tokio::time::timeout(
725+
Duration::from_secs(DEFAULT_TX_LOOKUP_TIMEOUT_SECS),
726+
self.api_client.test_mempool_accept(tx),
727+
);
728+
729+
match timeout_fut.await {
730+
Ok(res) => res.map_err(|e| {
731+
log_error!(
732+
self.logger,
733+
"Failed to test mempool accept for transaction {}: {}",
734+
tx.compute_txid(),
735+
e
736+
);
737+
Error::TxLookupFailed
738+
}),
739+
Err(e) => {
740+
log_error!(
741+
self.logger,
742+
"Failed to test mempool accept for transaction {} due to timeout: {}",
743+
tx.compute_txid(),
744+
e
745+
);
746+
log_trace!(
747+
self.logger,
748+
"Failed test mempool accept transaction bytes: {}",
749+
log_bytes!(tx.encode())
750+
);
751+
Err(Error::TxLookupTimeout)
752+
},
753+
}
754+
}
755+
756+
pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
757+
let timeout_fut = tokio::time::timeout(
758+
Duration::from_secs(DEFAULT_TX_LOOKUP_TIMEOUT_SECS),
759+
self.api_client.get_raw_transaction(txid),
760+
);
761+
762+
match timeout_fut.await {
763+
Ok(res) => res.map_err(|e| {
764+
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
765+
Error::TxLookupFailed
766+
}),
767+
Err(e) => {
768+
log_error!(self.logger, "Failed to get transaction {} due to timeout: {}", txid, e);
769+
Err(Error::TxLookupTimeout)
770+
},
771+
}
772+
}
722773
}
723774

724775
#[derive(Clone)]
@@ -1337,6 +1388,34 @@ impl BitcoindClient {
13371388
.collect();
13381389
Ok(evicted_txids)
13391390
}
1391+
1392+
/// Tests whether the provided transaction would be accepted by the mempool.
1393+
pub(crate) async fn test_mempool_accept(
1394+
&self, tx: &Transaction,
1395+
) -> Result<bool, RpcClientError> {
1396+
match self {
1397+
BitcoindClient::Rpc { rpc_client, .. } => {
1398+
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
1399+
},
1400+
BitcoindClient::Rest { rpc_client, .. } => {
1401+
// We rely on the internal RPC client to make this call, as this
1402+
// operation is not supported by Bitcoin Core's REST interface.
1403+
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
1404+
},
1405+
}
1406+
}
1407+
1408+
async fn test_mempool_accept_inner(
1409+
rpc_client: Arc<RpcClient>, tx: &Transaction,
1410+
) -> Result<bool, RpcClientError> {
1411+
let tx_serialized = bitcoin::consensus::encode::serialize_hex(tx);
1412+
let tx_array = serde_json::json!([tx_serialized]);
1413+
1414+
rpc_client
1415+
.call_method::<TestMempoolAcceptResponse>("testmempoolaccept", &[tx_array])
1416+
.await
1417+
.map(|resp| resp.0)
1418+
}
13401419
}
13411420

13421421
impl BlockSource for BitcoindClient {
@@ -1517,6 +1596,23 @@ impl TryInto<SubmitPackageResponse> for JsonResponse {
15171596
}
15181597
}
15191598

1599+
pub(crate) struct TestMempoolAcceptResponse(pub bool);
1600+
1601+
impl TryInto<TestMempoolAcceptResponse> for JsonResponse {
1602+
type Error = String;
1603+
fn try_into(self) -> Result<TestMempoolAcceptResponse, String> {
1604+
let array =
1605+
self.0.as_array().ok_or("Failed to parse testmempoolaccept response".to_string())?;
1606+
let first =
1607+
array.first().ok_or("Empty array response from testmempoolaccept".to_string())?;
1608+
let allowed = first
1609+
.get("allowed")
1610+
.and_then(|v| v.as_bool())
1611+
.ok_or("Missing 'allowed' field in testmempoolaccept response".to_string())?;
1612+
Ok(TestMempoolAcceptResponse(allowed))
1613+
}
1614+
}
1615+
15201616
#[derive(Debug, Clone)]
15211617
pub(crate) struct MempoolEntry {
15221618
/// The transaction id

0 commit comments

Comments
 (0)