Problem
The receiver treats a proposal accepted by the Payjoin directory as though the transaction was already observed on the Bitcoin network.
After posting the proposal, processReceiveSession writes the proposed txid into Receive.txid, even though the adjacent comment correctly notes that directory acceptance does not mean the sender accepted or broadcast it:
|
// flush persisted state before sending the proposal so a crash after send can replay correctly |
|
await persister.flush(); |
|
|
|
const rr = receiver.createPostRequest(receiveSess.ohttpRelay ?? randomRelay()); |
|
const responseBuffer = await fetchBufferResponse(rr.request); |
|
|
|
// Note: a success response here doesn't mean the sender accepted the PSBT. |
|
// Fallback tx broadcast is handled separately after a timeout if the payjoin tx is not seen. |
|
const result = receiver.processResponse(responseBuffer, rr.clientResponse).save(persister); |
|
logger.debug(processReceiveSession, 'processed proposal response:', result); |
|
|
|
if (txid) { |
|
const updateResult = await db.receive.update({ |
|
where: { id: receiveSess.id }, |
|
data: { |
|
txid, |
|
amount: updateAmount, |
|
fee: totalFee, |
|
receiverFee, |
|
receiverInAmount: receiverTotalInputAmount, |
|
receiverOutAmount: receiverTotalOutputAmount, |
|
senderInAmount: senderTotalInputAmount, |
|
senderOutAmount: senderTotalOutputAmount, |
|
txInputs, |
|
txOutputs, |
|
} |
|
}); |
|
logger.info(processReceiveSession, 'updated session with txid:', txid, receiveSess.id, updateResult); |
The receive cron only restores sessions and broadcasts fallbacks when txid is null. It also only schedules fallbacks for sessions with failedTs; a sender that simply takes the proposal and disappears does not set failedTs:
|
export async function restoreReceiveSessions(config: Config) { |
|
logger.info(restoreReceiveSessions, 'restoring receive sessions'); |
|
|
|
await cleanupSeenInputs(Number(config.PAYJOIN_RECEIVE_EXPIRY) * 1000); |
|
|
|
const { replicaId, totalReplicas } = Utils.replicaInfo(); |
|
|
|
// attempt to process all "current" receive sessions |
|
const allSessions = await db.receive.findMany({ |
|
where: { |
|
bip21: { not: null }, |
|
txid: null, |
|
confirmedTs: null, |
|
cancelledTs: null, |
|
expiryTs: { |
|
gt: new Date() |
|
}, |
|
session: { not: null } |
|
} |
|
}); |
|
const sessions = allSessions.filter(session => { |
|
return session.id % totalReplicas === (replicaId - 1); |
|
}); |
|
logger.info(restoreReceiveSessions, `found ${sessions.length} sessions to restore`); |
|
|
|
await Promise.all( |
|
sessions.map(receiveSess => processReceiveSession(receiveSess, config)) |
|
); |
|
|
|
// attempt to broadcast any fallback txs that payjoin failed but fallback has not been broadcast |
|
const allFailedSessions = await db.receive.findMany({ |
|
where: { |
|
confirmedTs: null, |
|
cancelledTs: null, |
|
failedTs: { |
|
lte: new Date(Date.now() - 2 * 60 * 1000) // Current date minus 2 minutes |
|
}, |
|
fallbackTxHex: { not: null }, |
|
txid: null, |
|
} |
|
}); |
|
const failedSessions = allFailedSessions.filter(session => { |
|
return session.id % totalReplicas === (replicaId - 1); |
|
}); |
|
logger.info(restoreReceiveSessions, `found ${failedSessions.length} failed sessions to broadcast`); |
|
await Promise.all( |
|
failedSessions.map(receiveSess => broadcastFallback(receiveSess, config)) |
|
); |
|
} |
|
|
|
async function processReceiveSession(receiveSess: Receive, config: Config) { |
|
// lock on both id and address - cancel uses id, watch uses address |
|
await lock.acquire([receiveSess.id.toString(), receiveSess.address!], async () => { |
|
logger.info(processReceiveSession, 'restoring session:', receiveSess.id); |
|
|
|
if (receiveSess.txid) { |
|
// @todo this should potentially check for a fallback timeout period and broadcast the fallback tx |
|
logger.info(processReceiveSession, 'session already has txid:', receiveSess.txid); |
|
return; |
|
} |
This removes the main economic protection against receiver UTXO probing: an automated receiver must broadcast the sender's valid fallback after the Payjoin deadline when neither transaction was seen.
Responsibility boundary
rust-payjoin/PDK should remain wallet- and node-agnostic. It already exposes the Monitor, cancellation, pending-fallback, and session-history primitives.
This service owns the Cyphernode client, receive-session persistence, cron lifecycle, and transaction broadcast, so driving those PDK states belongs here. It should not be delegated to the JSON-RPC caller, which does not have the PSBT or receiver-session state needed to do it safely.
Minimal implementation
- Add a nullable, indexed
proposalTxid field. Keep txid for a transaction actually observed by the node, irrespective of whether it is the proposal or fallback.
- Ensure the Original/fallback transaction is persisted before selecting or signing a receiver input. The current session-history extraction can be retained; this is an ordering/durability requirement, not a new storage subsystem.
- Once the finalized proposal txid is known, persist
proposalTxid and flush the PDK proposal state before the network POST. This covers the ambiguous crash/timeout case where the directory received the proposal but the acknowledgement did not reach this process. After processing the response, persist the returned PDK Monitor. Never set txid or mark the receive unconfirmed merely because the POST succeeded.
- Restore and drive
Monitor sessions after proposal delivery. Query the node for both known txids and pass the result through the PDK monitor transition.
- At
expiryTs, under the existing per-session/distributed lock, re-read the row and recheck both transactions. If neither is present:
- cancel the PDK monitor into its pending-fallback state;
- broadcast the persisted/PDK fallback transaction;
- record
fallbackTs and txid only after node acceptance or after finding the same transaction already known to the node;
- close the PDK pending-fallback state.
- Treat node lookup failures as an unknown outcome and retry later; they must never be interpreted as "not found" and trigger a broadcast. If fallback broadcast reports a conflict, recheck both known txids before deciding the outcome.
No new job queue, broadcast table, or fee-policy engine is needed. This fits the existing receive cron and session lock, and should retain the existing Original transaction broadcast-suitability/minimum-fee validation.
Acceptance criteria
Non-goals
- Account-level rate limits and minimum-deposit policy.
- General-purpose chain monitoring outside these two known transactions.
- A new fallback fee-bumping policy.
- Receiver UTXO unlock policy, which should consume the terminal outcomes produced here in a separate issue.
Protocol references
Problem
The receiver treats a proposal accepted by the Payjoin directory as though the transaction was already observed on the Bitcoin network.
After posting the proposal,
processReceiveSessionwrites the proposed txid intoReceive.txid, even though the adjacent comment correctly notes that directory acceptance does not mean the sender accepted or broadcast it:payjoin/src/cron/receive.ts
Lines 535 to 562 in 09bee86
The receive cron only restores sessions and broadcasts fallbacks when
txidis null. It also only schedules fallbacks for sessions withfailedTs; a sender that simply takes the proposal and disappears does not setfailedTs:payjoin/src/cron/receive.ts
Lines 21 to 80 in 09bee86
This removes the main economic protection against receiver UTXO probing: an automated receiver must broadcast the sender's valid fallback after the Payjoin deadline when neither transaction was seen.
Responsibility boundary
rust-payjoin/PDK should remain wallet- and node-agnostic. It already exposes theMonitor, cancellation, pending-fallback, and session-history primitives.This service owns the Cyphernode client, receive-session persistence, cron lifecycle, and transaction broadcast, so driving those PDK states belongs here. It should not be delegated to the JSON-RPC caller, which does not have the PSBT or receiver-session state needed to do it safely.
Minimal implementation
proposalTxidfield. Keeptxidfor a transaction actually observed by the node, irrespective of whether it is the proposal or fallback.proposalTxidand flush the PDK proposal state before the network POST. This covers the ambiguous crash/timeout case where the directory received the proposal but the acknowledgement did not reach this process. After processing the response, persist the returned PDKMonitor. Never settxidor mark the receive unconfirmed merely because the POST succeeded.Monitorsessions after proposal delivery. Query the node for both known txids and pass the result through the PDK monitor transition.expiryTs, under the existing per-session/distributed lock, re-read the row and recheck both transactions. If neither is present:fallbackTsandtxidonly after node acceptance or after finding the same transaction already known to the node;No new job queue, broadcast table, or fee-policy engine is needed. This fits the existing receive cron and session lock, and should retain the existing Original transaction broadcast-suitability/minimum-fee validation.
Acceptance criteria
Receive.txidor reportunconfirmedbefore node observation.proposalTxid.proposalTxid, never a network payment outcome.Non-goals
Protocol references