Repository navigation
Expose InputPair outpoint over FFI - #1654
Conversation
Coverage Report for CI Build 28430306062Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage increased (+0.3%) to 85.526%Details
Uncovered ChangesNo uncovered changes found. Coverage Regressions269 previously-covered lines in 11 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 |
DanGould
left a comment
There was a problem hiding this comment.
Approach ACK. Thank you for laying out the rationale, I understand why this is required in the BTCPay Server plugin now and how other wallets might need it.
That said, i don't think the tests I see are really necessary for reasons I attempt to lay out in the comments.
Almost there, thanks for sticking through the tedious part of this one.
| } | ||
|
|
||
| #[test] | ||
| fn input_pair_outpoint_returns_previous_output() { |
There was a problem hiding this comment.
This name does not make sense to me. The outpoint function does not return a utxo, it just returns the outpoint.This is also a borderline presence test for the function, it's not actually doing any calculation so I'd rather just drop the test.
Drop the Rust and C# tests that only assert getter presence. Keep the FFI accessor itself unchanged because BTCPay still needs it.
|
Ah, mutant |
Add Rust coverage for the txid and vout returned by outpoint(). This kills the cargo-mutants replacement with Default::default().
New commit 8c69ac4 should cover this |
|
ok csharp taking over an hour that's crazy |
|
I assume it had something to do with this |
Point the rust-payjoin submodule at upstream payjoin/rust-payjoin master commit f4a6008a, which includes the merged InputPair outpoint accessor (payjoin/rust-payjoin#1654). The previously tracked fork branch does not contain that change yet. Between the old pin and f4a6008a upstream upgraded uniffi to 0.31.2, which renamed the generated C# record accessors from camelCase fields to PascalCase properties, so the plugin call sites are renamed to match.
Point the rust-payjoin submodule at upstream payjoin/rust-payjoin master commit f4a6008a, which includes the merged InputPair outpoint accessor (payjoin/rust-payjoin#1654). The previously tracked fork branch does not contain that change yet. Between the old pin and f4a6008a upstream upgraded uniffi to 0.31.2, which renamed the generated C# record accessors from camelCase fields to PascalCase properties, so the plugin call sites are renamed to match.
* Update rust-payjoin for the InputPair outpoint FFI Point the rust-payjoin submodule at upstream payjoin/rust-payjoin master commit f4a6008a, which includes the merged InputPair outpoint accessor (payjoin/rust-payjoin#1654). The previously tracked fork branch does not contain that change yet. Between the old pin and f4a6008a upstream upgraded uniffi to 0.31.2, which renamed the generated C# record accessors from camelCase fields to PascalCase properties, so the plugin call sites are renamed to match. * Use rust-payjoin privacy-preserving receiver input selection Replace the deterministic smallest-first receiver coin choice with rust-payjoin's try_preserving_privacy, so UIH2 avoidance is decided by the library instead of plugin heuristics. A receiver wallet adapter maps wallet coins to FFI InputPairs and resolves the selected input back to its ReceivedCoin through the new InputPair outpoint accessor. When a privacy-selected coin loses its reservation race to another session, the candidate is removed and selection is retried over the remaining coins. * Test the receiver input contribution flow Wrap the rust-payjoin proposal calls behind an injectable seam so the selection and retry logic stays free of FFI types, translating the library's selection and contribution errors into plugin exceptions at that boundary. Document the retry model where it lives: candidates are snapshotted once per attempt and failed entries removed locally, because the wallet view does not exclude coins reserved by concurrent sessions and rebuilding mid-attempt would re-offer the input that just failed; each poll tick re-enters with a fresh set. Adds focused tests for the contribution flow: successful selection, reservation conflict with reselection, exhaustion when every candidate is reserved, contribution rejection with reselection, unmappable selection, and library selection failure. * Release input candidates deterministically and add boundary tests The FFI input pairs built for the candidate set hold native state and were only released by finalizers; the attempt now disposes every candidate once it completes, on success and failure alike, since the library clones what it keeps when an input is contributed. Adds boundary tests that exercise the wallet-coin to InputPair mapping and the selected-input roundtrip with real library objects, alongside the existing reservation-race coverage against the real session store. * Document why an exhausted contribution attempt ends the session Transient reservation races resolve inside the attempt by reselecting from the remaining candidates, so an exhausted attempt means nothing was contributable. Keeping the session alive to retry later would let it reserve freshly confirmed coins for a sender that already gave up, starving future payjoins until the monitoring deadline - the concurrency integration tests assert exactly this property. Ending the session mirrors how the v1 endpoint answers unavailability, and the sender's original transaction still pays the invoice.
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