Repository navigation
Conversation
Expose detailed per-input finalization outcomes without changing the existing finalize_psbt API. Preserve malformed input index and length errors and cover finalization, failure mapping, output metadata, and Swift/Kotlin binding round trips.
There was a problem hiding this comment.
Awesome stuff! Thanks for working on this. I left a bunch of little comments to address or discuss. Of course @reez is the king of this upstream PR so I defer to him in case anything here in my comments/review is wrong.
Annex
I propose a more explicit Kotlin test that showcases a PSBT going from CouldNotSatisfy to Finalized to AlreadyFinalized. Let me know what you think.
@Test
fun tryFinalizePsbtFailsOnUnsignedInputs() {
runBlocking {
val regtestEnv = RegEnv.connectTo(
host = "10.0.2.2",
walletName = "faucet",
username = "regtest",
password = "password",
)
val wallet = Wallet(
descriptor = BIP84_DESCRIPTOR,
changeDescriptor = BIP84_CHANGE_DESCRIPTOR,
network = Network.REGTEST,
persister = Persister.newInMemory()
)
// Fund the wallet and sync it.
val address = wallet.revealNextAddress(KeychainKind.EXTERNAL).address
val txid = Txid.fromString(regtestEnv.send(address.toString(), 0.001, 2.0))
regtestEnv.mine(1)
val esploraClient = EsploraClient(ESPLORA_REGTEST_URL)
// Wait for the Esplora client to see the transaction. Try 5x per second for 20 seconds.
for (i in 0..99) {
if (esploraClient.getTx(txid) != null) break
delay(200.milliseconds)
}
val update = esploraClient.fullScan(wallet.startFullScan().build(), 10uL, 1uL)
wallet.applyUpdate(update)
// Build a transaction but don't sign it.
val recipient = wallet.revealNextAddress(KeychainKind.EXTERNAL).address.scriptPubkey()
val psbt = TxBuilder()
.addRecipient(recipient, Amount.fromSat(10_000uL))
.finish(wallet)
// Every input is ours, but none can be satisfied without a signature.
val unsigned = wallet.tryFinalizePsbt(psbt)
assertFalse(unsigned.isFinalized)
assertTrue(unsigned.outcomes.isNotEmpty())
unsigned.outcomes.values.forEach { assertIs<FinalizeInputOutcome.CouldNotSatisfy>(it) }
// Once signed (without finalizing), the same inputs finalize.
wallet.sign(
psbt,
SignOptions(
trustWitnessUtxo = false,
assumeHeight = null,
allowAllSighashes = false,
tryFinalize = false,
signWithTapInternalKey = true,
allowGrinding = true,
)
)
val signed = wallet.tryFinalizePsbt(psbt)
assertTrue(signed.isFinalized)
assertEquals(unsigned.outcomes.keys, signed.outcomes.keys)
signed.outcomes.values.forEach { assertEquals(FinalizeInputOutcome.Finalized, it) }
// Finalizing again leaves the PSBT finalized, with every input already finalized.
val again = wallet.tryFinalizePsbt(psbt)
assertTrue(again.isFinalized)
assertEquals(signed.outcomes.keys, again.outcomes.keys)
again.outcomes.values.forEach { assertEquals(FinalizeInputOutcome.AlreadyFinalized, it) }
}
}| } | ||
| } | ||
|
|
||
| /// Snapshot of a wallet PSBT finalization attempt. |
There was a problem hiding this comment.
The docs on the Rust side use this instead, and I'd like to stay as close as we can to the Rust API docs unless they do not apply or make sense.
/// The outcome of a PSBT finalization attempt.
| pub struct FinalizePsbtOutcome { | ||
| /// Whether all inputs are finalized after this call. | ||
| pub is_finalized: bool, | ||
| /// Outcomes keyed by the zero-based transaction input index. |
There was a problem hiding this comment.
In this case it's ok to keep the docs different, as we expose fields instead of methods and the Rust docs mention the concept of borrowing, which is not present here in the bindings.
| CouldNotSatisfy { error: MiniscriptError }, | ||
| } | ||
|
|
||
| impl From<bdk_wallet::FinalizeInputOutcome> for FinalizeInputOutcome { |
There was a problem hiding this comment.
To stay in line with our approach throughout this codebase, I think you could import and rename, which would then allow you to use the renamed type here and in other places in line with how we do it elsewhere.
use bdk_wallet::FinalizeInputOutcome as BdkFinalizeInputOutcome;
impl From<BdkFinalizeInputOutcome> for FinalizeInputOutcome {There was a problem hiding this comment.
Updated both outcome conversions to use Bdk-prefixed imports in f79b5b0. Also aligned the outcome summary and wallet method docs with upstream, retaining field-oriented wording where the bindings differ.
| pub outcomes: HashMap<u64, FinalizeInputOutcome>, | ||
| } | ||
|
|
||
| impl From<bdk_wallet::FinalizePsbtOutcome> for FinalizePsbtOutcome { |
There was a problem hiding this comment.
Same as above for the import and rename.
|
|
||
| /// The finalization status of a single PSBT input. | ||
| #[derive(Debug, uniffi::Enum)] | ||
| pub enum FinalizeInputOutcome { |
There was a problem hiding this comment.
The newer versions of uniffi-rs support adding methods to Enums, and so I think you should be able to add a method called is_finalized() which could mirror what's available upstream (https://docs.rs/bdk_wallet/3.2.0/src/bdk_wallet/types.rs.html#161-163).
impl FinalizeInputOutcome {
/// Whether the input is finalized after this call.
pub fn is_finalized(&self) -> bool {
matches!(self, Self::AlreadyFinalized | Self::Finalized)
}
}There was a problem hiding this comment.
Added and exported is_finalized() in f79b5b0. It works with the pinned UniFFI version. Rust tests cover all four variants, and the Swift/Kotlin tests now call the generated isFinalized() method.
| /// Unlike [`Self::finalize_psbt`], timelocks are evaluated from the transaction fields | ||
| /// without [`SignOptions`], and output metadata is not cleared. |
There was a problem hiding this comment.
These two lines sent me down a rabbit hole a little bit, so thanks for that!
I think we should use the upstream docs here, as they capture well what's happening with the method.
@reez question for you: is there a reason why try_finalize_psbt behaves a bit differently than finalize_psbt and doesn't strip metadata from the outputs? I assume that's a privacy measure applied to the finalize_psbt method, and so users just thinking they want to replace one for the other now find themselves actually using a method that behaves slightly differently. I know it's an upstream choice so it's cool to have it here too, but just wondering.
| import kotlin.test.assertFalse | ||
|
|
||
| @RunWith(AndroidJUnit4::class) | ||
| class FinalizePsbtTest { |
There was a problem hiding this comment.
Thanks for adding Kotlin and Swift tests!
I'd love to see a more realistic test that shows the psbt going from CouldNotSatisfy to Finalized to AlreadyFinalized if we can. This should be easy on the Kotlin side since we have access to Regtest. I'll put my proposed test in the main review comment.
There was a problem hiding this comment.
Added the proposed regtest transition test in f79b5b0, including an explicit failure if Esplora does not index the funding transaction within the polling window. It checks CouldNotSatisfy -> Finalized -> AlreadyFinalized, stable input indices, and the per-input helper. Kept the small fixture test for MissingDescriptor coverage. Android test compilation passes locally; the regtest execution still needs CI. Rust: 87 passed, 6 ignored; Swift: 22 passed.
Export the per-input is_finalized helper, align docs and upstream type aliases, and add a regtest Kotlin test for unsigned, finalized, and already-finalized transitions.
Generate fresh BIP84 descriptors for the funded finalization test. Funding the shared BIP84 fixture caused the empty-wallet full-scan callback test to inspect 51 scripts instead of 50.
Description
Closes #1059. Intended for 3.2.0.
Expose
Wallet::try_finalize_psbtfrom bdk_wallet 3.2.0 without changing the existingfinalize_psbtAPI.FinalizePsbtOutcomerecord containingis_finalizedand an input-indexed map of typed outcomes.MiniscriptErrorforCouldNotSatisfy.FinalizePsbtError::InputIndexOutOfBoundswith both index and length for malformed PSBTs.Notes to the reviewers
Keeping this in draft for API-shape feedback and CI validation.
The method mutates the supplied PSBT in place. Per-input failures are outcomes, not thrown errors. As upstream specifies, timelocks are evaluated from transaction fields and output metadata is not cleared. No
SignOptionsargument is added.The outcome is an FFI record rather than an object;
is_finalizedis a snapshot field and input indices useu64. The existingPsbtFinalizeErrorbelongs toPsbt::finalize; this additive wallet API has its own error type to preserve upstream index/length details without changing existing variants.Validation
cargo test --locked: 86 passed, 6 existing network-dependent tests ignored.cargo fmt --checkandcargo clippy --locked --all-targets -- -D warnings: passed.swift testagainst a locally built macOS-only debug XCFramework: 22 passed. The local linker emitted deployment-target warnings for native dependency objects; this was not a release artifact build.Rust tests cover successful finalization and PSBT mutation, repeated finalization, unchanged output metadata, missing descriptors, unsatisfied inputs, and malformed input-map index details. Binding tests currently cover mixed
MissingDescriptor/AlreadyFinalizedoutcomes.Documentation
Changelog
changelog: added(maintainer label requested).Checklists