Skip to content

Add Payjoin Receiver Support#746

Open
Camillarhi wants to merge 1 commit intolightningdevkit:mainfrom
Camillarhi:payjoin-receiver
Open

Add Payjoin Receiver Support#746
Camillarhi wants to merge 1 commit intolightningdevkit:mainfrom
Camillarhi:payjoin-receiver

Conversation

@Camillarhi
Copy link
Contributor

@Camillarhi Camillarhi commented Jan 8, 2026

This PR adds support for receiving payjoin payments in LDK Node. This is currently a work in progress and implements the receiver side of the payjoin protocol.

  • Add session store for persisting payjoin receiver events across restarts
  • Implement KVStorePayjoinReceiverPersister to handle session persistence
  • Add Payjoin as a PaymentKind to the payment store
  • Add event polling mechanism for active payjoin sessions
  • Wire up payjoin payment request and receive flows

Note on persistence: The payjoin library currently only supports synchronous persistence, but they're working on adding async support(payjoin/rust-payjoin#1235). This PR sets up the persistence structure (KVStorePayjoinReceiverPersister), which will be updated to use async operations once the upstream PR is merged.

This PR will partially address #177

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Jan 8, 2026

I've assigned @tnull as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@DanGould
Copy link

We've merged the async persistence PR you mentioned. You might want to build your draft PR on the merged commit from there on until we cut you a release.

@Camillarhi
Copy link
Contributor Author

We've merged the async persistence PR you mentioned. You might want to build your draft PR on the merged commit from there on until we cut you a release.

Thanks for letting me know. I'll build on the merged commit.

@Camillarhi Camillarhi force-pushed the payjoin-receiver branch 5 times, most recently from 8f6ba65 to 6499918 Compare January 30, 2026 10:36
@DanGould
Copy link

Are you stuck? Did something in our library break CI @Camillarhi

@Camillarhi
Copy link
Contributor Author

Are you stuck? Did something in our library break CI @Camillarhi

Not stuck at all. I was just closing out some other PRs. Still working on this one, I'll let you know when it's ready.

@Camillarhi Camillarhi force-pushed the payjoin-receiver branch 2 times, most recently from 12a41ad to eb97832 Compare February 18, 2026 23:21
@Camillarhi Camillarhi force-pushed the payjoin-receiver branch 3 times, most recently from 120b089 to 8cc2a31 Compare February 23, 2026 15:03
@Camillarhi Camillarhi force-pushed the payjoin-receiver branch 9 times, most recently from 1e1efcd to 5fe1a5c Compare March 10, 2026 12:06
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.
@Camillarhi Camillarhi marked this pull request as ready for review March 12, 2026 09:50
@Camillarhi
Copy link
Contributor Author

Marking this as ready for review.

The core receiver flow is implemented, including session persistence, PSBT handling, input contribution, mempool monitoring for payjoin transactions, and node restart recovery.

Two things still pending that I'll follow up with:

  • Integration tests
  • Processing/updating confirmation state for payjoin transactions in the payment and pending payment stores

Happy to get early feedback on the overall approach in the meantime.

@tnull @DanGould

@ldk-reviews-bot ldk-reviews-bot requested a review from tnull March 12, 2026 09:51
Copy link

@bc1cindy bc1cindy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi there!

congrats and thanks for your work!

I talked to @spacebear21 and we agreed that I would review this PR, so I took a first look and here are some points:

needs to run cargo fmt --all

"InvalidLnurl",
"PayjoinNotConfigured",
"PayjoinSessionCreationFailed",
"PayjoinSessionFailed"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PayjoinSessionFailed is used for infrastructure failures (coin selection, InputPair construction) and network errors (poll/post requests). The upstream lib distinguishes these via SelectionError, InputContributionError, and ReceiverError , would it be worth preserving that granularity so callers have more info and know how to act?


pub(crate) async fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> {
let timeout_fut = tokio::time::timeout(
Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since testmempoolaccept is local validation only, shouldn't this use DEFAULT_TX_LOOKUP_TIMEOUT_SECS?

Err(e) => {
// Check if it's a "not found" error
let error_str = e.to_string();
if error_str.contains("No such mempool or blockchain transaction")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matching errors by string seems fragile, as the message varies by server implementation.
e.g. tested: ssl://electrum.blockstream.info:60002: Protocol(String("missing transaction")) , which doesn't match either string here and Ok(None) would never be returned. the caller sees a sync failure instead

self.runtime.spawn_blocking(move || electrum_client.transaction_get(&txid_copy));
let timeout_fut = tokio::time::timeout(
Duration::from_secs(
self.sync_config.timeouts_config.lightning_wallet_sync_timeout_secs,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a single onchain tx lookup, should this use tx_broadcast_timeout_secs (10s) instead of lightning_wallet_sync_timeout_secs (30s)? or would a dedicated tx_lookup_timeout_secs field make more sense?

&self, proposal: Receiver<Monitor>, persister: &KVStorePayjoinReceiverPersister,
) -> Result<(), Error> {
// On a session resumption, the receiver will resume again in this state.
let poll_interval = tokio::time::Duration::from_millis(200);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems aggressive for a daemon (payjoin-cli uses 200ms because its a one-shot CLI with manual retry) but for ldk node maybe 1-5 seconds would be more appropriate?

})
.save_async(persister)
.await
.map_err(|_| Error::PersistenceFailed)?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finalize_proposal returns MaybeTransientTransition to distinguish transient from fatal errors, but .map_err(|_| Error::PersistenceFailed) collapses both into the same error. Transient failures won't be retried and will surface as PersistenceFailed, which is misleading

(4, secret, option),
}
},
(11, Payjoin)=>{

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all other PaymentKind variants use even TLV tags (0, 2, 4, 6, 8, 10), was 11 intentional, or should this be 12?

Arc::clone(&kv_store),
Arc::clone(&logger),
)),
Err(e) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

payjoin_session_store_res doesn't handle ErrorKind::NotFound, which will occur on any node that has never used payjoin. fix suggestion in read_payjoin_sessions

#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "869fd348c3ca0c78f439d2f31181f4d798c6b20e" }

payjoin = { git = "https://github.com/payjoin/rust-payjoin.git", package = "payjoin", default-features = false, features = ["v2", "io"] }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this pin a rev?

PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE,
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
)
.await?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list() returns NotFound when the namespace has never been written, maybe consider something like this handling it?

rust.or_else(|e| if e.kind() == lightning::io::ErrorKind::NotFound {
    Ok(vec![])
} else {
    Err(e)
})?;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants