Expose InputPair outpoint over FFI#1654
Conversation
Coverage Report for CI Build 28107957766Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage decreased (-0.006%) to 85.218%Details
Uncovered ChangesNo uncovered changes found. Coverage Regressions64 previously-covered lines in 3 files lost coverage.
Coverage Stats
💛 - Coveralls |
DanGould
left a comment
There was a problem hiding this comment.
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.
|
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. |
|
Seems BTCPay cannot exactly match payjoin-cli because BTCPay’s wallet model keeps UTXO metadata in a type called |
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.
|
The CLI and BTCPay have different wallet boundaries. In 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. 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 BTCPay starts from its own wallet model. 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 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);
}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 The adapter keeps the two wallet representations paired. 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);
}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 |
This exposes the outpoint carried by a receiver
InputPairthrough a smalloutpoint()accessor and regenerates the C# binding surface.BTCPay uses
WantsInputs::try_preserving_privacythrough UniFFI in ValeraFinebits/btcpayserver-payjoin-plugin#59. The selectedInputPaircrosses the FFI boundary as an opaque object, while BTCPay keeps spendable wallet metadata in its ownReceivedCoinmodel. BTCPay needs the selected inputOutPointto map that choice back to the coin it can reserve across sessions and later sign.Disclosure: co-authored by GPT-5 Codex