Skip to content

Expose InputPair outpoint over FFI#1654

Open
chavic wants to merge 2 commits into
payjoin:masterfrom
chavic:chavic/inputpair-outpoint-ffi
Open

Expose InputPair outpoint over FFI#1654
chavic wants to merge 2 commits into
payjoin:masterfrom
chavic:chavic/inputpair-outpoint-ffi

Conversation

@chavic

@chavic chavic commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

This exposes the outpoint carried by a receiver InputPair through a small outpoint() accessor and regenerates the C# binding surface.

BTCPay uses WantsInputs::try_preserving_privacy through UniFFI in ValeraFinebits/btcpayserver-payjoin-plugin#59. The selected InputPair crosses the FFI boundary as an opaque object, while BTCPay keeps spendable wallet metadata in its own ReceivedCoin model. BTCPay needs the selected input OutPoint to map that choice back to the coin it can reserve across sessions and later sign.

Disclosure: co-authored by GPT-5 Codex

@coveralls

coveralls commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 28107957766

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-0.006%) to 85.218%

Details

  • Coverage decreased (-0.006%) from the base build.
  • Patch coverage: 10 of 10 lines across 1 file are fully covered (100%).
  • 64 coverage regressions across 3 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

64 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
payjoin-mailroom/src/lib.rs 42 67.67%
payjoin-mailroom/src/ohttp_relay/mod.rs 11 82.61%
payjoin/src/core/receive/v2/mod.rs 11 91.28%

Coverage Stats

Coverage Status
Relevant Lines: 14761
Covered Lines: 12579
Line Coverage: 85.22%
Coverage Strength: 369.92 hits per line

💛 - Coveralls

@DanGould DanGould left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Based on our conversation this seems like the right approach but I want to validate that before this gets merged.

PR draft in the BTCPayServer plugin implementation pointing to this PR's commit hash (or even branch name, since it's just a draft) would proving this is the right shape to solve the problem. That'll serve as draft to merge there as soon as this pr is merged/released anyhow so it's nothing extra than needs to be done no matter what.

Showing where this is needed in the BTCPayServer plugin will also reveal the counterpart (or lack thereof) in the payjoin-cli reference and make sure that the reference covers this usecase. I'd like to see some sort of link to where the similar behavior exists in payjoin-cli in the PR body and in the BTCPayServer PR.

I think outpoint makes a lot more sense since that refers to the specific txid+index that's needed and is a distinct thing from "Previous output" which ime refers to the complete information of the transaction output, not just the reference that the outpoint serves as. The in-flight terminology BIP is a great resource for customary naming of transaction parts like this.

Finally, I want to make sure it makes sense to expose this exact data as an accessor rather than any subfield of the InputPair itself, which a reference to payjoin-cli should bring to light. I know UniFFI gives us options in terms of Record field/Record method/Object method to return an outpoint and would like to see rationale of why the chosen approach is the right one among the available options.

@DanGould

Copy link
Copy Markdown
Member

Oh, and if this were my PR and I couldn't find a reference to this behavior in payjoin-cli, I'd go and find out why the other implementaitons didn't need this accessor, and then write up either why BTCPayServer is a unique case that requires this or update the BTCPayServer Plugin to use the same pattern as the others.

@chavic

chavic commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Seems BTCPay cannot exactly match payjoin-cli because BTCPay’s wallet model keeps UTXO metadata in a type called ReceivedCoin. The payjoin library selects an opaque FFI InputPair, but BTCPay must map that selection back to the ReceivedCoin type so it can reserve the coin across sessions and sign it later. The CLI avoids this because its wallet boundary already returns InputPairs and contributes the selected one directly.

Use outpoint() instead of previous_outpoint() for the InputPair accessor exposed to FFI.

The shorter name matches the data returned: the txid and vout reference for the UTXO spent by the input pair.
@chavic chavic changed the title Expose InputPair previous outpoint over FFI Expose InputPair outpoint over FFI Jun 24, 2026
@chavic

chavic commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

The CLI and BTCPay have different wallet boundaries.

In payjoin-cli, the wallet already returns payjoin InputPairs.

source

pub fn list_unspent(&self) -> Result<Vec<InputPair>> {
    let unspent = tokio::task::block_in_place(|| {
        tokio::runtime::Handle::current()
            .block_on(async { self.rpc.list_unspent(None, None, None, None, None).await })
    })
    .context("Failed to list unspent")?;
    Ok(unspent.0.into_iter().map(input_pair_from_corepc).collect())
}

The selected value is immediately contributed back into the payjoin proposal.

source

async fn contribute_inputs(
    &self,
    proposal: Receiver<WantsInputs>,
    persister: &ReceiverPersister,
) -> Result<()> {
    let wallet = self.wallet();
    let candidate_inputs = wallet.list_unspent()?;

  	...

    let selected_input = proposal.try_preserving_privacy(candidate_inputs)?;
    let proposal =
        proposal.contribute_inputs(vec![selected_input])?.commit_inputs().save(persister)?;
    self.apply_fee_range(proposal, persister).await
}

So the CLI does not need to map the selected input back to another wallet type. Its wallet boundary has already converted spendable UTXOs into InputPair, and the selected InputPair is the thing it contributes.

BTCPay starts from its own wallet model.

source

public class ReceivedCoin
{
    public Script ScriptPubKey { get; set; }
    public OutPoint OutPoint { get; set; }
    public DateTimeOffset Timestamp { get; set; }
    public KeyPath KeyPath { get; set; }
    public IMoney Value { get; set; }
    public Coin Coin { get; set; }
    public long Confirmations { get; set; }
    public BitcoinAddress Address { get; set; }
    public int KeyIndex { get; set; }
}

That ReceivedCoin is still needed after selection because BTCPay reserves the contributed coin across receiver sessions and later signs/ finalizes only the receiver inputs.

source

using var selectedInput = proposal.TryPreservingPrivacy(candidates.Select(candidate => candidate.Input).ToArray());
selected = _walletAdapter.ResolveSelectedCandidate(candidates, selectedInput.Outpoint());

if (selected is null)
{
    contributionFailures.Add("selected receiver input could not be mapped back to a wallet coin");
    break;
}

var selectedOutPoint = selected.Coin.OutPoint.ToString();
WantsInputs? withInputs = null;
try
{
    withInputs = proposal.ContributeInputs(new[] { selected.Input });
    var contributedCoins = new[] { selected.Coin };
    if (_sessionStore.TryReserveContributedInput(storeId, invoiceId, contributedCoins[0].OutPoint, reservationExpiresAt))
    {
        return ReceiverInputContributionResult.Success(withInputs, contributedCoins);
    }

source

EnsureContributedInputsPresent(proposalPsbt, receiverCoins);
derivationScheme.RebaseKeyPaths(proposalPsbt);
proposalPsbt.RebaseKeyPaths(signingKeySettings.AccountKey, rootedKeyPath);
proposalPsbt.SignAll(derivationScheme.AccountDerivation, accountKey, rootedKeyPath);
FinalizeContributedInputs(proposalPsbt, receiverCoins);
ClearSenderInputFinalization(proposalPsbt, receiverCoins);
ClearPartialSignatures(proposalPsbt);
ClearHdKeyPaths(proposalPsbt);

The FFI call returns an opaque InputPair, so BTCPay needs one stable identifier from the selected object to recover the matching ReceivedCoin. Simply exposing the Rust fields is not available through the current UniFFI object shape unless InputPair is changed from an object wrapper into a record-style API.

The adapter keeps the two wallet representations paired.

source

public async Task<IReadOnlyList<PayjoinReceiverInputCandidate>> GetInputCandidatesAsync(
    string storeId,
    CancellationToken cancellationToken)
{
    var confirmed = await GetConfirmedReceiverCoinsAsync(storeId, cancellationToken).ConfigureAwait(false);
    return confirmed
        .Select(coin => new PayjoinReceiverInputCandidate(CreateInputPair(coin), coin))
        .ToArray();
}

public PayjoinReceiverInputCandidate? ResolveSelectedCandidate(
    IReadOnlyList<PayjoinReceiverInputCandidate> candidates,
    PayjoinOutPoint selectedOutPoint)
{
    return candidates.SingleOrDefault(candidate =>
        string.Equals(candidate.Coin.OutPoint.Hash.ToString(), selectedOutPoint.txid, StringComparison.OrdinalIgnoreCase) &&
        candidate.Coin.OutPoint.N == selectedOutPoint.vout);
}

source

private static PayjoinInputPair CreateInputPair(ReceivedCoin coin)
{
    var txin = new PayjoinTxIn(
        new PayjoinOutPoint(coin.OutPoint.Hash.ToString(), coin.OutPoint.N),
        Array.Empty<byte>(),
        uint.MaxValue,
        Array.Empty<byte[]>());
    var txout = new PayjoinTxOut(checked((ulong)coin.Coin.Amount.Satoshi), coin.ScriptPubKey.ToBytes());
    var psbtIn = new PayjoinPsbtInput(txout, null, null);
    return new PayjoinInputPair(txin, psbtIn, null);
}

internal sealed record PayjoinReceiverInputCandidate(PayjoinInputPair Input, ReceivedCoin Coin)
{
    public NBitcoin.OutPoint OutPoint => Coin.OutPoint;
}

So the extra method lets a foreign-language receiver using the opaque FFI object recover the selected input’s outpoint and map it back to its own wallet, reservation, and signing state. The full btpay PR is here: #59

@chavic chavic marked this pull request as ready for review June 26, 2026 13:44
@chavic chavic requested a review from DanGould June 26, 2026 13:45
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.

3 participants