Skip to content

Separate proposed and observed txids and complete the receiver fallback lifecycle #6

Description

@BullishNode

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:

  • payjoin/src/cron/receive.ts

    Lines 535 to 562 in 09bee86

    // 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

  1. 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.
  2. 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.
  3. 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.
  4. Restore and drive Monitor sessions after proposal delivery. Query the node for both known txids and pass the result through the PDK monitor transition.
  5. 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.
  6. 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

  • A sender can receive a proposal, never broadcast it, and the fallback is broadcast once the session expires.
  • Posting to the directory does not set Receive.txid or report unconfirmed before node observation.
  • Observing the Payjoin transaction prevents fallback broadcast and completes the PDK monitor state.
  • Observing the fallback records the fallback outcome and completes the PDK monitor state.
  • Concurrent/duplicate cron runs cannot produce inconsistent terminal state or repeated accounting callbacks.
  • Restarting after proposal delivery resumes monitoring and still broadcasts the fallback at expiry.
  • Crashing or timing out during the proposal POST cannot leave a delivered proposal without a durable proposalTxid.
  • Tests cover the race where the Payjoin appears immediately before the expiry fallback check.
  • A temporary node lookup error delays the decision and does not cause fallback broadcast.
  • A directory acknowledgement by itself records only proposalTxid, never a network payment outcome.

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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions