Skip to content

feat: expose Wallet::try_finalize_psbt outcomes - #1144

Open
j-kon wants to merge 3 commits into
bitcoindevkit:masterfrom
j-kon:feat/1059-try-finalize-psbt
Open

j-kon wants to merge 3 commits into
bitcoindevkit:masterfrom
j-kon:feat/1059-try-finalize-psbt

Conversation

@j-kon

@j-kon j-kon commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Description

Closes #1059. Intended for 3.2.0.

Expose Wallet::try_finalize_psbt from bdk_wallet 3.2.0 without changing the existing finalize_psbt API.

  • Return a FinalizePsbtOutcome record containing is_finalized and an input-indexed map of typed outcomes.
  • Preserve all four upstream outcome variants, reusing MiniscriptError for CouldNotSatisfy.
  • Return FinalizePsbtError::InputIndexOutOfBounds with both index and length for malformed PSBTs.
  • Add Rust coverage and network-free Swift/Kotlin binding tests.

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 SignOptions argument is added.

The outcome is an FFI record rather than an object; is_finalized is a snapshot field and input indices use u64. The existing PsbtFinalizeError belongs to Psbt::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 --check and cargo clippy --locked --all-targets -- -D warnings: passed.
  • Generated Swift bindings and ran swift test against 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.
  • Generated and compiled Kotlin bindings with JNA 5.14.0; a Kotlin/JVM smoke test exercised the native finalization call and outcome map successfully.
  • Android instrumentation test added but not run locally. Full platform/release builds remain for CI.

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/AlreadyFinalized outcomes.

Documentation

Changelog

changelog: added (maintainer label requested).

Checklists

  • Signed all commits
  • Followed contribution guidelines
  • Ran formatting and Clippy
  • Added exactly one changelog label
  • Linked upstream documentation
  • Added tests and API documentation

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.

@thunderbiscuit thunderbiscuit 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.

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) }
    }
}

Comment thread bdk-ffi/src/types.rs Outdated
}
}

/// Snapshot of a wallet PSBT finalization attempt.

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.

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.

Comment thread bdk-ffi/src/types.rs
pub struct FinalizePsbtOutcome {
/// Whether all inputs are finalized after this call.
pub is_finalized: bool,
/// Outcomes keyed by the zero-based transaction input index.

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.

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.

Comment thread bdk-ffi/src/types.rs Outdated
CouldNotSatisfy { error: MiniscriptError },
}

impl From<bdk_wallet::FinalizeInputOutcome> for FinalizeInputOutcome {

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.

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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread bdk-ffi/src/types.rs Outdated
pub outcomes: HashMap<u64, FinalizeInputOutcome>,
}

impl From<bdk_wallet::FinalizePsbtOutcome> for FinalizePsbtOutcome {

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.

Same as above for the import and rename.

Comment thread bdk-ffi/src/types.rs

/// The finalization status of a single PSBT input.
#[derive(Debug, uniffi::Enum)]
pub enum FinalizeInputOutcome {

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.

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)
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread bdk-ffi/src/wallet.rs Outdated
Comment on lines +775 to +776
/// Unlike [`Self::finalize_psbt`], timelocks are evaluated from the transaction fields
/// without [`SignOptions`], and output metadata is not cleared.

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.

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 {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

j-kon added 2 commits October 15, 2026 20:58
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.
@j-kon
j-kon marked this pull request as ready for review October 16, 2026 04:47
@j-kon
j-kon requested a review from a team as a code owner October 16, 2026 04:47

This branch has not been deployed

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

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

Expose Wallet::try_finalize_psbt API

2 participants