Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SDK Documentation Spark
Request API Key

Unilateral exit

A unilateral exit moves your Spark balance onto the Bitcoin blockchain without needing the Spark operators to sign the withdrawal for you. It exists as a safety net: if the operators ever stop cooperating with normal withdrawals, you can still recover your funds on-chain.

A unilateral exit is a last resort. It is a multi-step, on-chain process that needs your own Bitcoin (in a separate UTXO) to pay mining fees, and it can take several days to finish because of on-chain timelocks. When a normal cooperative withdrawal is available, it is always cheaper and faster: prefer it.

Before you start

Three things are important to know before you build an exit:

  • The operators must currently be reachable. Both quoting and building the exit fetch your pre-signed transactions from the Spark operators. Recovering purely from locally stored data, with the operators offline or uncooperative, is not supported yet. In other words, this protects you against operators who refuse to co-sign a withdrawal, but not yet against operators who are completely unreachable.
  • You pay the fees from your own UTXO. The pre-signed transactions carry no fee, so each is fee-bumped with a child transaction (CPFP) funded by a Bitcoin UTXO you provide. That UTXO must be native SegWit (a witness-program script). P2WPKH and P2TR are handled by the built-in signer; any other witness program (for example a P2WSH multisig) works through the CpfpFundingKind::CustomCpfpFundingKind.CUSTOMCpfpFundingKind.customCpfpFundingKind.CustomCpfpFundingKind.CustomCpfpFundingKind.CustomCpfpFundingKind.CustomCpfpFundingKindCustomCpfpFundingKind.Custom funding kind and a custom signer (see The signer). Legacy (non-SegWit) scripts are rejected.
  • You broadcast the transactions yourself. The SDK builds and signs the full set but never broadcasts. You send them to the network over time, in order, as their timelocks mature. See Broadcasting the transactions.

How it works

Your balance is held in a tree of pre-signed Bitcoin transactions. Each leaf is a portion of the balance. To move a leaf on-chain you broadcast the chain of transactions from the tree down to that leaf, then a refund transaction, then a final sweep to your destination address. Because the pre-signed transactions pay no fee on their own, each one is broadcast together with a CPFP child that pays its fee.

The exit is two calls:

  1. prepare_unilateral_exitprepare_unilateral_exitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitPrepareUnilateralExitPrepareUnilateralExit quotes the exit: it picks which leaves to exit and reports the exact fee and how much to fund, without needing any funding UTXOs yet.
  2. unilateral_exitunilateral_exitunilateralExitunilateralExitunilateralExitunilateralExitunilateralExitUnilateralExitUnilateralExit takes that quote plus your funding UTXOs and a signer, and returns the complete, signed set of transactions to broadcast.

A single leaf

With one leaf there is no fan-out: your funding UTXO pays the fees directly. You broadcast the tree transactions top to bottom, each with its CPFP child as a package, then the refund once its timelock matures, then the sweep.

Single-leaf unilateral exit

The blue transactions come pre-signed and fixed; you cannot change them. The grey CPFP children and the green sweep are built for you from the funding you supply, and are what actually pay the fees and deliver the funds to your address.

Multiple leaves

Exiting several leaves at once starts with a fan-out transaction that splits a single funding UTXO into one output per branch. Leaves that share ancestors in the tree share those transactions too, so a shared ancestor is broadcast only once. Every branch's refund is then pulled into a single sweep.

Multi-leaf unilateral exit

Leaf denominations and exit cost

Every leaf is exited by its own chain of transactions, so it carries its own on-chain fee whatever its value. The more leaves your balance is spread across, and the smaller they are, the more of it goes to fees on the way out, and the more low-value leaves an ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto exit abandons as uneconomical dust.

How the balance is split into leaves is governed by the SDK's leaf optimization, which balances everyday payment experience against unilateral exit value. More, smaller denominations let payments go out without leaf swaps, while fewer, larger denominations cost less to exit. The default leans toward payment experience, which suits most wallets, since a unilateral exit is a rare last resort. See Custom leaf optimization to understand this tradeoff and adjust it if your use case calls for it.

Quote the exit

Call prepare_unilateral_exitprepare_unilateral_exitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitPrepareUnilateralExitPrepareUnilateralExit with the target fee_rate_sat_per_vbytefee_rate_sat_per_vbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbyteFeeRateSatPerVbyteFeeRateSatPerVbyte, the funding_kindfunding_kindfundingKindfundingKindfundingKindfundingKindfundingKindFundingKindFundingKind of UTXO you will pay fees with, your destinationdestinationdestinationdestinationdestinationdestinationdestinationDestinationDestination address, and a selectionselectionselectionselectionselectionselectionselectionSelectionSelection. ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto exits every leaf worth more than its own exit cost; ExitLeafSelection::SpecificExitLeafSelection.SPECIFICExitLeafSelection.specificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelectionSpecificExitLeafSelection.Specific exits exactly the leaves you name.

The quote returns a PrepareUnilateralExitResponsePrepareUnilateralExitResponsePrepareUnilateralExitResponsePrepareUnilateralExitResponsePrepareUnilateralExitResponsePrepareUnilateralExitResponsePrepareUnilateralExitResponsePrepareUnilateralExitResponsePrepareUnilateralExitResponse. Its fields tell you how much Bitcoin to gather and how to structure it:

  • recoverable_value_satrecoverable_value_satrecoverableValueSatrecoverableValueSatrecoverableValueSatrecoverableValueSatrecoverableValueSatRecoverableValueSatRecoverableValueSat is the total value of the selected leavesleavesleavesleavesleavesleavesleavesLeavesLeaves, and total_fee_sattotal_fee_sattotalFeeSattotalFeeSattotalFeeSattotalFeeSattotalFeeSatTotalFeeSatTotalFeeSat is the on-chain fee to recover it. Compare them to decide whether the exit is worth it at the current fee rate.
  • single_utxo_funding_satsingle_utxo_funding_satsingleUtxoFundingSatsingleUtxoFundingSatsingleUtxoFundingSatsingleUtxoFundingSatsingleUtxoFundingSatSingleUtxoFundingSatSingleUtxoFundingSat is the simplest option: fund one UTXO of at least this many satoshis and the SDK fans it out across branches.
  • per_branch_fundingper_branch_fundingperBranchFundingperBranchFundingperBranchFundingperBranchFundingperBranchFundingPerBranchFundingPerBranchFunding lets you skip the fan-out (and its fanout_fee_satfanout_fee_satfanoutFeeSatfanoutFeeSatfanoutFeeSatfanoutFeeSatfanoutFeeSatFanoutFeeSatFanoutFeeSat) by funding one UTXO per branch, each of at least the amount in its PerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFunding entry.

So you do not have to guess how much to send or how many UTXOs to prepare: the quote tells you both.

Under ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto a leaf is kept when its value exceeds its own exit cost, measured per leaf. That per-leaf measure does not include the shared fanout_fee_satfanout_fee_satfanoutFeeSatfanoutFeeSatfanoutFeeSatfanoutFeeSatfanoutFeeSatFanoutFeeSatFanoutFeeSat, which the single-UTXO path pays once for the whole exit. So when you fund a multi-leaf exit from a single UTXO, the fan-out fee can push the total above what you recover, even though every leaf looked profitable on its own.

Two rules keep an exit from ever costing more than it returns:

  1. Before funding, require recoverable_value_satrecoverable_value_satrecoverableValueSatrecoverableValueSatrecoverableValueSatrecoverableValueSatrecoverableValueSatRecoverableValueSatRecoverableValueSat to exceed total_fee_sattotal_fee_sattotalFeeSattotalFeeSattotalFeeSattotalFeeSattotalFeeSatTotalFeeSatTotalFeeSat. These are the actual totals for the quote, fan-out fee included. If the margin is thin or negative, do not proceed as quoted.
  2. Prefer per-branch funding. Funding one UTXO per branch (per_branch_fundingper_branch_fundingperBranchFundingperBranchFundingperBranchFundingperBranchFundingperBranchFundingPerBranchFundingPerBranchFunding) skips the fan-out entirely, so there is no shared fee. Because ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto already keeps only leaves worth more than their own cost, a per-branch-funded auto exit is always net-positive.

If the single-UTXO total is not worth it, either fund per branch, or narrow the set: re-quote with ExitLeafSelection::SpecificExitLeafSelection.SPECIFICExitLeafSelection.specificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelectionSpecificExitLeafSelection.Specific naming only the higher-value leaves (dropping the marginal ones removes their cost and can turn the total positive), or wait for a lower fee rate.

If nothing is selected (under ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto no leaf is worth exiting at the given fee rate, or there is nothing to exit) the response comes back empty rather than as an error. Check leavesleavesleavesleavesleavesleavesleavesLeavesLeaves before gathering funding.

Rust
let quote = sdk
    .prepare_unilateral_exit(PrepareUnilateralExitRequest {
        fee_rate_sat_per_vbyte: 2,
        funding_kind: CpfpFundingKind::P2wpkh,
        destination: "bc1q...your-destination-address".to_string(),
        selection: ExitLeafSelection::Auto,
    })
    .await?;

println!(
    "Recovering {} sats for {} sats in fees",
    quote.recoverable_value_sat, quote.total_fee_sat
);
println!("Fund a single UTXO of at least {} sats", quote.single_utxo_funding_sat);
Swift
let quote = try await sdk.prepareUnilateralExit(
    request: PrepareUnilateralExitRequest(
        feeRateSatPerVbyte: 2,
        fundingKind: .p2wpkh,
        destination: "bc1q...your-destination-address",
        selection: .auto
    )
)

print("Recovering \(quote.recoverableValueSat) sats for \(quote.totalFeeSat) sats in fees")
print("Fund a single UTXO of at least \(quote.singleUtxoFundingSat) sats")
Kotlin
val quote = sdk.prepareUnilateralExit(
    PrepareUnilateralExitRequest(
        feeRateSatPerVbyte = 2u,
        fundingKind = CpfpFundingKind.P2wpkh,
        destination = "bc1q...your-destination-address",
        selection = ExitLeafSelection.Auto
    )
)

println("Recovering ${quote.recoverableValueSat} sats for ${quote.totalFeeSat} sats in fees")
println("Fund a single UTXO of at least ${quote.singleUtxoFundingSat} sats")
C#
var quote = await sdk.PrepareUnilateralExit(
    request: new PrepareUnilateralExitRequest(
        feeRateSatPerVbyte: 2,
        fundingKind: new CpfpFundingKind.P2wpkh(),
        destination: "bc1q...your-destination-address",
        selection: new ExitLeafSelection.Auto()
    )
);

Console.WriteLine($"Recovering {quote.recoverableValueSat} sats for {quote.totalFeeSat} sats in fees");
Console.WriteLine($"Fund a single UTXO of at least {quote.singleUtxoFundingSat} sats");
Javascript
const quote = await sdk.prepareUnilateralExit({
  feeRateSatPerVbyte: 2,
  fundingKind: { type: 'p2wpkh' },
  destination: 'bc1q...your-destination-address',
  selection: { type: 'auto' }
})

console.log(`Recovering ${quote.recoverableValueSat} sats for ${quote.totalFeeSat} sats in fees`)
console.log(`Fund a single UTXO of at least ${quote.singleUtxoFundingSat} sats`)
React Native
const quote = await sdk.prepareUnilateralExit({
  feeRateSatPerVbyte: BigInt(2),
  fundingKind: new CpfpFundingKind.P2wpkh(),
  destination: 'bc1q...your-destination-address',
  selection: new ExitLeafSelection.Auto()
})

console.log(`Recovering ${quote.recoverableValueSat} sats for ${quote.totalFeeSat} sats in fees`)
console.log(`Fund a single UTXO of at least ${quote.singleUtxoFundingSat} sats`)
Flutter
PrepareUnilateralExitRequest request = PrepareUnilateralExitRequest(
  feeRateSatPerVbyte: BigInt.from(2),
  fundingKind: const CpfpFundingKind.p2Wpkh(),
  destination: "bc1q...your-destination-address",
  selection: const ExitLeafSelection.auto(),
);

PrepareUnilateralExitResponse quote = await sdk.prepareUnilateralExit(request: request);

print("Recovering ${quote.recoverableValueSat} sats for ${quote.totalFeeSat} sats in fees");
print("Fund a single UTXO of at least ${quote.singleUtxoFundingSat} sats");
Python
quote = await sdk.prepare_unilateral_exit(
    request=PrepareUnilateralExitRequest(
        fee_rate_sat_per_vbyte=2,
        funding_kind=CpfpFundingKind.P2WPKH(),
        destination="bc1q...your-destination-address",
        selection=ExitLeafSelection.AUTO(),
    ),
)

logging.debug(
    f"Recovering {quote.recoverable_value_sat} sats "
    f"for {quote.total_fee_sat} sats in fees"
)
logging.debug(f"Fund a single UTXO of at least {quote.single_utxo_funding_sat} sats")
Go
quote, err := sdk.PrepareUnilateralExit(breez_sdk_spark.PrepareUnilateralExitRequest{
	FeeRateSatPerVbyte: 2,
	FundingKind:        breez_sdk_spark.CpfpFundingKindP2wpkh{},
	Destination:        "bc1q...your-destination-address",
	Selection:          breez_sdk_spark.ExitLeafSelectionAuto{},
})
if err != nil {
	return nil, err
}

log.Printf("Recovering %d sats for %d sats in fees", quote.RecoverableValueSat, quote.TotalFeeSat)
log.Printf("Fund a single UTXO of at least %d sats", quote.SingleUtxoFundingSat)

Build the exit

Gather funding that meets the quote, then call unilateral_exitunilateral_exitunilateralExitunilateralExitunilateralExitunilateralExitunilateralExitUnilateralExitUnilateralExit with the quote, your real CpfpInputCpfpInputCpfpInputCpfpInputCpfpInputCpfpInputCpfpInputCpfpInputCpfpInput funding UTXOs, and a signer. It returns a UnilateralExitResponseUnilateralExitResponseUnilateralExitResponseUnilateralExitResponseUnilateralExitResponseUnilateralExitResponseUnilateralExitResponseUnilateralExitResponseUnilateralExitResponse with the actual total_fee_sattotal_fee_sattotalFeeSattotalFeeSattotalFeeSattotalFeeSattotalFeeSatTotalFeeSatTotalFeeSat and the full transaction set.

If the funding is below what the exit needs it returns SdkError::InsufficientCpfpFundsSdkError.INSUFFICIENT_CPFP_FUNDSSdkError.insufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkErrorInsufficientCpfpFundsSdkError.InsufficientCpfpFunds. If one of the funding UTXOs has already been spent (for example by an earlier attempt) it returns SdkError::FundingUtxoConflictSdkError.FUNDING_UTXO_CONFLICTSdkError.fundingUtxoConflictSdkError.FundingUtxoConflictSdkError.FundingUtxoConflictSdkError.FundingUtxoConflictSdkError.FundingUtxoConflictSdkErrorFundingUtxoConflictSdkError.FundingUtxoConflict, which names the conflicting outpoint so you can supply fresh funding.

A very thin-margin exit can fail even when the funding is sufficient: if the recoverable value net of fees would leave the swept output below the destination address's dust limit, the sweep cannot be built and the exit fails. Exit higher-value leaves with ExitLeafSelection::SpecificExitLeafSelection.SPECIFICExitLeafSelection.specificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelectionSpecificExitLeafSelection.Specific, lower the fee_rate_sat_per_vbytefee_rate_sat_per_vbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbyteFeeRateSatPerVbyteFeeRateSatPerVbyte, or wait for a cheaper fee rate.

The set it builds depends on what is already on-chain. Because each CPFP child spends the previous one, the exit is one connected chain, so to continue it correctly the SDK reads confirmed on-chain state through its chain service: a step already confirmed comes back as ConfirmationStatus::ConfirmedConfirmationStatus.CONFIRMEDConfirmationStatus.confirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatusConfirmedConfirmationStatus.Confirmed and is not rebuilt. If the chain service cannot resolve a step, the SDK falls back to the status the operators reported: a node the operators already consider on-chain is left as-is rather than fee-bumped (bumping an already-confirmed node would invalidate the rest of the chain), and any node whose state still cannot be determined comes back as ConfirmationStatus::UnverifiedConfirmationStatus.UNVERIFIEDConfirmationStatus.unverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatusUnverifiedConfirmationStatus.Unverified and is treated as not yet confirmed rather than failing the build. You still get the full set back; broadcasting an already-confirmed transaction is harmless, and re-running once the chain service recovers resolves the status. For a more reliable source you can supply your own chain service (see Customizing the SDK).

Rust
let secret_key_bytes: Vec<u8> = hex::decode("your-secret-key-hex")?;
let signer = signer::single_key_cpfp_signer(secret_key_bytes)?;

let response = sdk
    .unilateral_exit(
        UnilateralExitRequest {
            prepared: quote,
            funding_inputs: vec![CpfpInput::P2wpkh {
                txid: "your-utxo-txid".to_string(),
                vout: 0,
                value: 50_000,
                pubkey: "your-compressed-pubkey-hex".to_string(),
            }],
        },
        signer,
    )
    .await?;

for tx in &response.transactions {
    if let Some(blocks) = tx.csv_timelock_blocks {
        println!("{}: wait {} blocks after its parents confirm", tx.txid, blocks);
    }
}
Swift
let secretKeyBytes = Data(hexString: "your-secret-key-hex")!
let signer = try singleKeyCpfpSigner(secretKeyBytes: secretKeyBytes)

let response = try await sdk.unilateralExit(
    request: UnilateralExitRequest(
        prepared: quote,
        fundingInputs: [
            .p2wpkh(
                txid: "your-utxo-txid",
                vout: 0,
                value: 50_000,
                pubkey: "your-compressed-pubkey-hex"
            )
        ]
    ),
    signer: signer
)

for tx in response.transactions {
    if let blocks = tx.csvTimelockBlocks {
        print("\(tx.txid): wait \(blocks) blocks after its parents confirm")
    }
}
Kotlin
try {
    val secretKeyBytes = "your-secret-key-hex".hexToByteArray()
    val signer = singleKeyCpfpSigner(secretKeyBytes)

    val response = sdk.unilateralExit(
        UnilateralExitRequest(
            prepared = quote,
            fundingInputs = listOf(
                CpfpInput.P2wpkh(
                    txid = "your-utxo-txid",
                    vout = 0u,
                    value = 50_000u,
                    pubkey = "your-compressed-pubkey-hex"
                )
            )
        ),
        signer
    )

    for (tx in response.transactions) {
        tx.csvTimelockBlocks?.let { blocks ->
            println("${tx.txid}: wait $blocks blocks after its parents confirm")
        }
    }
} catch (e: Exception) {
    // handle error
}
C#
var secretKeyBytes = Convert.FromHexString("your-secret-key-hex");
var signer = BreezSdkSparkMethods.SingleKeyCpfpSigner(secretKeyBytes);

var response = await sdk.UnilateralExit(
    request: new UnilateralExitRequest(
        prepared: quote,
        fundingInputs: new CpfpInput[]
        {
            new CpfpInput.P2wpkh(
                txid: "your-utxo-txid",
                vout: 0,
                value: 50_000,
                pubkey: "your-compressed-pubkey-hex"
            )
        }
    ),
    signer: signer
);

foreach (var tx in response.transactions)
{
    if (tx.csvTimelockBlocks != null)
    {
        Console.WriteLine($"{tx.txid}: wait {tx.csvTimelockBlocks} blocks after its parents confirm");
    }
}
Javascript
const secretKeyBytes = Buffer.from('your-secret-key-hex', 'hex')
const signer = singleKeyCpfpSigner(secretKeyBytes)

const response = await sdk.unilateralExit(
  {
    prepared: quote,
    fundingInputs: [{
      type: 'p2wpkh',
      txid: 'your-utxo-txid',
      vout: 0,
      value: 50_000,
      pubkey: 'your-compressed-pubkey-hex'
    }]
  },
  signer
)

for (const tx of response.transactions) {
  if (tx.csvTimelockBlocks != null) {
    console.log(`${tx.txid}: wait ${tx.csvTimelockBlocks} blocks after its parents confirm`)
  }
}
React Native
const secretKeyBytes = Buffer.from('your-secret-key-hex', 'hex')
// Buffer.buffer is a shared pool slab; slice to this key's own bytes.
const signer = singleKeyCpfpSigner(
  secretKeyBytes.buffer.slice(
    secretKeyBytes.byteOffset,
    secretKeyBytes.byteOffset + secretKeyBytes.byteLength
  )
)

const response = await sdk.unilateralExit(
  {
    prepared: quote,
    fundingInputs: [
      new CpfpInput.P2wpkh({
        txid: 'your-utxo-txid',
        vout: 0,
        value: BigInt(50_000),
        pubkey: 'your-compressed-pubkey-hex'
      })
    ]
  },
  signer
)

for (const tx of response.transactions) {
  if (tx.csvTimelockBlocks != null) {
    console.log(`${tx.txid}: wait ${tx.csvTimelockBlocks} blocks after its parents confirm`)
  }
}
Flutter
List<int> secretKeyBytes = hex.decode("your-secret-key-hex");

UnilateralExitResponse response = await sdk.unilateralExit(
  request: UnilateralExitRequest(
    prepared: quote,
    fundingInputs: [
      CpfpInput.p2Wpkh(
        txid: "your-utxo-txid",
        vout: 0,
        value: BigInt.from(50000),
        pubkey: "your-compressed-pubkey-hex",
      ),
    ],
  ),
  signerSecretKey: Uint8List.fromList(secretKeyBytes),
);

for (UnilateralExitTransaction tx in response.transactions) {
  if (tx.csvTimelockBlocks != null) {
    print("${tx.txid}: wait ${tx.csvTimelockBlocks} blocks after its parents confirm");
  }
}
Python
secret_key_bytes = bytes.fromhex("your-secret-key-hex")
signer = single_key_cpfp_signer(secret_key_bytes=secret_key_bytes)

response = await sdk.unilateral_exit(
    request=UnilateralExitRequest(
        prepared=quote,
        funding_inputs=[
            CpfpInput.P2WPKH(  # type: ignore[list-item]
                txid="your-utxo-txid",
                vout=0,
                value=50_000,
                pubkey="your-compressed-pubkey-hex",
            )
        ],
    ),
    signer=signer,
)

for tx in response.transactions:
    if tx.csv_timelock_blocks is not None:
        logging.debug(
            f"{tx.txid}: wait {tx.csv_timelock_blocks} blocks after its parents confirm"
        )
Go
secretKeyBytes, err := hex.DecodeString("your-secret-key-hex")
if err != nil {
	return err
}
signer, err := breez_sdk_spark.SingleKeyCpfpSigner(secretKeyBytes)
if err != nil {
	return err
}

response, err := sdk.UnilateralExit(breez_sdk_spark.UnilateralExitRequest{
	Prepared: quote,
	FundingInputs: []breez_sdk_spark.CpfpInput{
		breez_sdk_spark.CpfpInputP2wpkh{
			Txid:   "your-utxo-txid",
			Vout:   0,
			Value:  50_000,
			Pubkey: "your-compressed-pubkey-hex",
		},
	},
}, signer)
if err != nil {
	return err
}

for _, tx := range response.Transactions {
	if tx.CsvTimelockBlocks != nil {
		fmt.Printf("%s: wait %d blocks after its parents confirm\n", tx.Txid, *tx.CsvTimelockBlocks)
	}
}

The signer

The CPFP children and the fan-out spend your funding UTXOs, so they have to be signed. The SDK does not hold your funding keys; it hands each unsigned transaction to a signer you provide.

The built-in single-key signer covers the common case: it signs P2WPKH and P2TR inputs from one secret key. For CpfpInput::P2trCpfpInput.P2TRCpfpInput.p2trCpfpInput.P2trCpfpInput.P2trCpfpInput.P2trCpfpInput.P2trCpfpInputP2trCpfpInput.P2tr funding, pass the internal, untweaked (BIP86) key, not the tweaked on-chain output key: the tweaked key derives a scriptPubKey that does not match the UTXO, so the transaction is rejected at broadcast. For anything else (a multisig, a hardware wallet, or keeping key material out of the SDK entirely) implement the CpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSigner interface and describe the funding with CpfpFundingKind::CustomCpfpFundingKind.CUSTOMCpfpFundingKind.customCpfpFundingKind.CustomCpfpFundingKind.CustomCpfpFundingKind.CustomCpfpFundingKind.CustomCpfpFundingKindCustomCpfpFundingKind.Custom (in the quote) and CpfpInput::CustomCpfpInput.CUSTOMCpfpInput.customCpfpInput.CustomCpfpInput.CustomCpfpInput.CustomCpfpInput.CustomCpfpInputCustomCpfpInput.Custom (in the build). Those carry the funding script_pubkey_hexscript_pubkey_hexscriptPubkeyHexscriptPubkeyHexscriptPubkeyHexscriptPubkeyHexscriptPubkeyHexScriptPubkeyHexScriptPubkeyHex and an upper-bound signed_input_weightsigned_input_weightsignedInputWeightsignedInputWeightsignedInputWeightsignedInputWeightsignedInputWeightSignedInputWeightSignedInputWeight so the fee stays exact for any witness program. The signer receives a serialized PSBT, signs the inputs that are not already finalized, and returns the serialized signed PSBT:

Whichever signer you use, the funding inputs must be native SegWit (a witness-program script; P2WPKH or P2TR with the built-in signer, any other witness program with a custom one). The exit refers to each transaction by an id it computes before signing, which only stays stable when the signature lives in the witness (native SegWit) rather than in the input script; legacy scripts are rejected, so your signer only ever has to sign native SegWit inputs.

Rust
struct MyCpfpSigner;

#[async_trait::async_trait]
impl signer::CpfpSigner for MyCpfpSigner {
    async fn sign_psbt(&self, psbt_bytes: Vec<u8>) -> Result<Vec<u8>, SignerError> {
        let signed_psbt_bytes = sign_psbt_with_your_keys(psbt_bytes)?;
        Ok(signed_psbt_bytes)
    }
}

fn sign_psbt_with_your_keys(psbt_bytes: Vec<u8>) -> Result<Vec<u8>, SignerError> {
    Ok(psbt_bytes)
}
Swift
class CustomCpfpSigner: CpfpSigner {
    func signPsbt(psbtBytes: Data) async throws -> Data {
        return try await signPsbtWithYourKeys(psbtBytes: psbtBytes)
    }

    private func signPsbtWithYourKeys(psbtBytes: Data) async throws -> Data {
        return psbtBytes
    }
}
Kotlin
class MyCpfpSigner : CpfpSigner {
    override suspend fun signPsbt(psbtBytes: ByteArray): ByteArray {
        return signPsbtWithYourKeys(psbtBytes)
    }

    private fun signPsbtWithYourKeys(psbtBytes: ByteArray): ByteArray {
        return psbtBytes
    }
}
C#
class MyCpfpSigner : CpfpSigner
{
    public async Task<byte[]> SignPsbt(byte[] psbtBytes)
    {
        return await SignPsbtWithYourKeys(psbtBytes);
    }

    async Task<byte[]> SignPsbtWithYourKeys(byte[] psbtBytes)
    {
        return await Task.FromResult(psbtBytes);
    }
}
Javascript
class CustomCpfpSigner implements CpfpSigner {
  async signPsbt (psbtBytes: Uint8Array): Promise<Uint8Array> {
    return await signPsbtWithYourKeys(psbtBytes)
  }
}

const signPsbtWithYourKeys = async (psbtBytes: Uint8Array): Promise<Uint8Array> => {
  return psbtBytes
}
React Native
class CustomCpfpSigner {
  signPsbt = async (psbtBytes: ArrayBuffer): Promise<ArrayBuffer> => {
    return await signPsbtWithYourKeys(psbtBytes)
  }
}

const signPsbtWithYourKeys = async (psbtBytes: ArrayBuffer): Promise<ArrayBuffer> => {
  return psbtBytes
}
Flutter
Future<void> buildExitWithSigner(BreezSdk sdk, PrepareUnilateralExitResponse quote) async {
  // Flutter cannot pass a foreign CpfpSigner, so it takes a signPsbt callback.
  UnilateralExitResponse response = await sdk.unilateralExitWithSigner(
    request: UnilateralExitRequest(
      prepared: quote,
      fundingInputs: [
        CpfpInput.p2Wpkh(
          txid: "your-utxo-txid",
          vout: 0,
          value: BigInt.from(50000),
          pubkey: "your-compressed-pubkey-hex",
        ),
      ],
    ),
    signPsbt: (Uint8List psbtBytes) async {
      return signPsbtWithYourKeys(psbtBytes);
    },
  );

  for (UnilateralExitTransaction tx in response.transactions) {
    if (tx.csvTimelockBlocks != null) {
      print("${tx.txid}: wait ${tx.csvTimelockBlocks} blocks after its parents confirm");
    }
  }
}

// Receives the serialized PSBT, signs the inputs that are not already
// finalized, and returns the serialized signed PSBT.
Future<Uint8List> signPsbtWithYourKeys(Uint8List psbtBytes) async {
  return psbtBytes;
}
Python
class CustomCpfpSigner(CpfpSigner):
    async def sign_psbt(self, psbt_bytes: bytes) -> bytes:
        return sign_psbt_with_your_keys(psbt_bytes)


def sign_psbt_with_your_keys(psbt_bytes: bytes) -> bytes:
    raise NotImplementedError("Sign the PSBT's non-finalized inputs with your keys")
Go
type MyCpfpSigner struct{}

func (MyCpfpSigner) SignPsbt(psbtBytes []byte) ([]byte, error) {
	return signPsbtWithYourKeys(psbtBytes)
}

func signPsbtWithYourKeys(psbtBytes []byte) ([]byte, error) {
	return psbtBytes, nil
}

Flutter

Flutter cannot pass a foreign CpfpSigner, so it exposes two exit calls. unilateralExit takes the funding secret key bytes and uses the built-in single-key signer. unilateralExitWithSigner takes a signPsbt callback that receives the serialized PSBT, signs the inputs that are not already finalized (any scheme), and returns the serialized signed PSBT.

Broadcast the transactions

The SDK does not broadcast anything. transactionstransactionstransactionstransactionstransactionstransactionstransactionsTransactionsTransactions is the complete, signed set in valid broadcast order, and it is yours to send to the network over time. Persist it, then broadcast each transaction once it is ready. A transaction is ready when every txid in its depends_ondepends_ondependsOndependsOndependsOndependsOndependsOnDependsOnDependsOn has confirmed and its csv_timelock_blockscsv_timelock_blockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlocksCsvTimelockBlocksCsvTimelockBlocks relative timelock has matured. Because of those timelocks, a full exit can span several days.

Broadcast each package together

Most steps come as a pair: a tree transaction and its cpfp_tx_hexcpfp_tx_hexcpfpTxHexcpfpTxHexcpfpTxHexcpfpTxHexcpfpTxHexCpfpTxHexCpfpTxHex CPFP child. The tree transaction pays no fee on its own, so a normal single-transaction broadcast rejects it; only the child makes the pair pay enough. Broadcast the two together, as a package, with a node that supports package relay, for example Bitcoin Core:

bitcoin-cli submitpackage '["<tx_hex>", "<cpfp_tx_hex>"]'

The fan-out and the sweep are the exceptions: each pays its own fee and has no CPFP child (cpfp_tx_hexcpfp_tx_hexcpfpTxHexcpfpTxHexcpfpTxHexcpfpTxHexcpfpTxHexCpfpTxHexCpfpTxHex is unset), so you broadcast it alone, as an ordinary transaction, anywhere — including a public endpoint such as POST https://mempool.space/api/tx. Most public broadcast APIs, including mempool.space, accept only one transaction at a time and cannot submit a package, so they reject the zero-fee tree transactions; use a package-relay-capable node (or service) for the pairs.

Wait for each step to confirm

Within a branch you broadcast one package, wait for it to confirm, then broadcast the next. This is a mempool relay limit, not a Bitcoin consensus rule: nodes relay an unconfirmed parent with at most one unconfirmed child (the "one-parent-one-child", or 1P1C, package), so a second still-unconfirmed package stacked on top would not propagate. Once a package confirms, the next one has a confirmed parent and can go out. (A refund's csv_timelock_blockscsv_timelock_blockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlocksCsvTimelockBlocksCsvTimelockBlocks is a separate wait, and that one is a consensus rule.)

Order and parallelism

Follow depends_ondepends_ondependsOndependsOndependsOndependsOndependsOnDependsOnDependsOn to order the set: a transaction can go out as soon as the transactions it lists have confirmed. With a single leaf this is one straight line, top to bottom. With several leaves the branches are largely independent, so to finish faster you can broadcast them in parallel and serialize only where depends_ondepends_ondependsOndependsOndependsOndependsOndependsOnDependsOnDependsOn actually links them:

  1. The fan-out first, and alone. It pays its own fee and has no CPFP child, so it is an ordinary single-transaction broadcast. Wait for it to confirm before any branch package — every branch's first package depends on it.
  2. Then the branch packages, each node transaction with its CPFP child. A shared ancestor appears once, listed in the depends_ondepends_ondependsOndependsOndependsOndependsOndependsOnDependsOnDependsOn of every branch that needs it, so you broadcast it a single time. Within a branch, send one package, wait for it to confirm, then the next (the 1P1C limit above); across branches you can work in parallel.
  3. The sweep last, and alone, once every refund in its depends_ondepends_ondependsOndependsOndependsOndependsOndependsOnDependsOnDependsOn has confirmed.

The transaction set

Each UnilateralExitTransactionUnilateralExitTransactionUnilateralExitTransactionUnilateralExitTransactionUnilateralExitTransactionUnilateralExitTransactionUnilateralExitTransactionUnilateralExitTransactionUnilateralExitTransaction in transactionstransactionstransactionstransactionstransactionstransactionstransactionsTransactionsTransactions carries:

  • kindkindkindkindkindkindkindKindKind: whether it is the fan-out, a tree node, a refund, or the sweep.
  • node_idnode_idnodeIdnodeIdnodeIdnodeIdnodeIdNodeIdNodeId: the tree node a transaction belongs to (the leaf id for a refund), unset for the fan-out and the sweep.
  • txidtxidtxidtxidtxidtxidtxidTxidTxid and tx_hextx_hextxHextxHextxHextxHextxHexTxHexTxHex: the signed transaction to broadcast.
  • cpfp_tx_hexcpfp_tx_hexcpfpTxHexcpfpTxHexcpfpTxHexcpfpTxHexcpfpTxHexCpfpTxHexCpfpTxHex: its signed CPFP child, to broadcast alongside tx_hextx_hextxHextxHextxHextxHextxHexTxHexTxHex as a package. Unset for the fan-out and the sweep, and for a step that is already confirmed.
  • csv_timelock_blockscsv_timelock_blockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlocksCsvTimelockBlocksCsvTimelockBlocks: the relative timelock, in blocks, that must mature before the transaction can confirm.
  • depends_ondepends_ondependsOndependsOndependsOndependsOndependsOnDependsOnDependsOn: the txids of other transactions in the set that must confirm first.
  • statusstatusstatusstatusstatusstatusstatusStatusStatus: whether the transaction is already on-chain. ConfirmationStatus::ConfirmedConfirmationStatus.CONFIRMEDConfirmationStatus.confirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatusConfirmedConfirmationStatus.Confirmed means it is done and can be skipped; ConfirmationStatus::UnconfirmedConfirmationStatus.UNCONFIRMEDConfirmationStatus.unconfirmedConfirmationStatus.UnconfirmedConfirmationStatus.UnconfirmedConfirmationStatus.UnconfirmedConfirmationStatus.UnconfirmedConfirmationStatusUnconfirmedConfirmationStatus.Unconfirmed is the normal state of a step that is not yet on-chain and that you must broadcast; ConfirmationStatus::UnverifiedConfirmationStatus.UNVERIFIEDConfirmationStatus.unverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatusUnverifiedConfirmationStatus.Unverified means its on-chain status could not be determined (see the troubleshooting table).

Resuming and increasing the fee

unilateral_exitunilateral_exitunilateralExitunilateralExitunilateralExitunilateralExitunilateralExitUnilateralExitUnilateralExit is safe to call again. It reads confirmed on-chain state on every call, so any step already confirmed comes back as ConfirmationStatus::ConfirmedConfirmationStatus.CONFIRMEDConfirmationStatus.confirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatus.ConfirmedConfirmationStatusConfirmedConfirmationStatus.Confirmed, and an interrupted exit resumes from where it stopped instead of starting over. You never re-supply a previously built exit transaction: the SDK re-discovers the confirmed steps — including a confirmed fan-out — from chain state itself. The only thing you ever pass back in is a confirmed fan-out's outputs, and only as fresh funding UTXOs when a higher fee rate needs more than they provide (as described just below).

For the most reliable resume, re-quote with ExitLeafSelection::SpecificExitLeafSelection.SPECIFICExitLeafSelection.specificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelectionSpecificExitLeafSelection.Specific naming the same leaves as your original quote, rather than ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto, then call unilateral_exitunilateral_exitunilateralExitunilateralExitunilateralExitunilateralExitunilateralExitUnilateralExitUnilateralExit again. Persist the leaf ids from that first quote so you can name them. Naming the leaves explicitly is the most dependable way to pick up an interrupted exit, including a leaf still waiting out its refund timelock.

The reported fee reflects on-chain progress. unilateral_exitunilateral_exitunilateralExitunilateralExitunilateralExitunilateralExitunilateralExitUnilateralExitUnilateralExit's total_fee_sattotal_fee_sattotalFeeSattotalFeeSattotalFeeSattotalFeeSattotalFeeSatTotalFeeSatTotalFeeSat is the actual fee of only the transactions it still returns, so a resume costs less than a fresh exit — already-confirmed steps are free. prepare_unilateral_exitprepare_unilateral_exitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitPrepareUnilateralExitPrepareUnilateralExit works from the operators' reported node state rather than a chain lookup, so it treats any node the operators already consider on-chain as paid; a partially-exited leaf therefore quotes cheaper, and its per_branch_fundingper_branch_fundingperBranchFundingperBranchFundingperBranchFundingperBranchFundingperBranchFundingPerBranchFundingPerBranchFunding drops to match.

To re-broadcast the same leaves at a higher fee, quote again with the same ExitLeafSelection::SpecificExitLeafSelection.SPECIFICExitLeafSelection.specificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelectionSpecificExitLeafSelection.Specific leaves and a higher fee_rate_sat_per_vbytefee_rate_sat_per_vbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbyteFeeRateSatPerVbyteFeeRateSatPerVbyte, then call unilateral_exitunilateral_exitunilateralExitunilateralExitunilateralExitunilateralExitunilateralExitUnilateralExitUnilateralExit again. The not-yet-confirmed transactions are rebuilt at the higher fee and replace the earlier ones by RBF; confirmed steps are left as they are. Once a fan-out has confirmed its outputs are fixed at the fee they were built with, so if the higher rate needs more than they provide the call returns SdkError::InsufficientCpfpFundsSdkError.INSUFFICIENT_CPFP_FUNDSSdkError.insufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkErrorInsufficientCpfpFundsSdkError.InsufficientCpfpFunds; because those outputs pay to your own funding script, you recover by quoting again and passing them back in as funding UTXOs, together with any extra funding needed.

Confirmed CPFP transactions hold funds the same way: once one confirms, your funds sit in its change output. To raise the fee beyond what a confirmed output covers, supply that output back in as a funding UTXO alongside the extra funding — list the confirmed output(s) first, then the new UTXO — so the rebuild spends the confirmed CPFP outputs together with the new funding rather than being capped by them. (Supplying the remaining unspent outputs yourself works too.)

Troubleshooting

ProblemCauseSolution
prepare_unilateral_exitprepare_unilateral_exitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitprepareUnilateralExitPrepareUnilateralExitPrepareUnilateralExit returns no leavesleavesleavesleavesleavesleavesleavesLeavesLeavesUnder ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto, no leaf is worth exiting at the current rateLower fee_rate_sat_per_vbytefee_rate_sat_per_vbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbyteFeeRateSatPerVbyteFeeRateSatPerVbyte or wait for cheaper on-chain fees (this is not an error)
A leaf you are mid-exit on is missing from a resumed ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto quoteThe resume reselected leaves with ExitLeafSelection::AutoExitLeafSelection.AUTOExitLeafSelection.autoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelection.AutoExitLeafSelectionAutoExitLeafSelection.Auto instead of naming themRe-quote with ExitLeafSelection::SpecificExitLeafSelection.SPECIFICExitLeafSelection.specificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelectionSpecificExitLeafSelection.Specific, naming the leaves from your original quote
total_fee_sattotal_fee_sattotalFeeSattotalFeeSattotalFeeSattotalFeeSattotalFeeSatTotalFeeSatTotalFeeSat is close to or above recoverable_value_satrecoverable_value_satrecoverableValueSatrecoverableValueSatrecoverableValueSatrecoverableValueSatrecoverableValueSatRecoverableValueSatRecoverableValueSatThe shared fan-out fee makes a single-UTXO multi-leaf exit uneconomicalFund one UTXO per branch (per_branch_fundingper_branch_fundingperBranchFundingperBranchFundingperBranchFundingperBranchFundingperBranchFundingPerBranchFundingPerBranchFunding) to drop the fan-out fee, exit fewer leaves with ExitLeafSelection::SpecificExitLeafSelection.SPECIFICExitLeafSelection.specificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelectionSpecificExitLeafSelection.Specific, or wait for a lower fee rate
The build/sweep fails with a "below the dust limit" errorThe recoverable value net of fees is below the destination's dust limitExit higher-value leaves with ExitLeafSelection::SpecificExitLeafSelection.SPECIFICExitLeafSelection.specificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelection.SpecificExitLeafSelectionSpecificExitLeafSelection.Specific, lower the fee_rate_sat_per_vbytefee_rate_sat_per_vbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbyteFeeRateSatPerVbyteFeeRateSatPerVbyte, or wait for a cheaper fee rate
SdkError::InsufficientCpfpFundsSdkError.INSUFFICIENT_CPFP_FUNDSSdkError.insufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkError.InsufficientCpfpFundsSdkErrorInsufficientCpfpFundsSdkError.InsufficientCpfpFundsFunding is below what the exit needsFund at least single_utxo_funding_satsingle_utxo_funding_satsingleUtxoFundingSatsingleUtxoFundingSatsingleUtxoFundingSatsingleUtxoFundingSatsingleUtxoFundingSatSingleUtxoFundingSatSingleUtxoFundingSat, or the amount in each PerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFundingPerBranchFunding
SdkError::FundingUtxoConflictSdkError.FUNDING_UTXO_CONFLICTSdkError.fundingUtxoConflictSdkError.FundingUtxoConflictSdkError.FundingUtxoConflictSdkError.FundingUtxoConflictSdkError.FundingUtxoConflictSdkErrorFundingUtxoConflictSdkError.FundingUtxoConflictA funding UTXO was already spent (e.g. a previous attempt)Supply fresh, unspent funding; the error names the conflicting outpoint
"min relay fee not met" when broadcastingThe package fee is too low for the networkIncrease fee_rate_sat_per_vbytefee_rate_sat_per_vbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbytefeeRateSatPerVbyteFeeRateSatPerVbyteFeeRateSatPerVbyte, rebuild, and re-broadcast (RBF)
"mandatory-script-verify-flag-failed"A CPFP child was not signed correctlyEnsure your CpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSignerCpfpSigner signs every non-finalized input
"non-BIP68-final"A relative timelock has not maturedWait the required csv_timelock_blockscsv_timelock_blockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlockscsvTimelockBlocksCsvTimelockBlocksCsvTimelockBlocks after the parent confirms
A tree transaction is rejected on its ownThe zero-fee parent was broadcast without its childBroadcast the parent and its cpfp_tx_hexcpfp_tx_hexcpfpTxHexcpfpTxHexcpfpTxHexcpfpTxHexcpfpTxHexCpfpTxHexCpfpTxHex together as a package
The sweep is rejectedNot every refund it spends has confirmedWait for all of the sweep's depends_ondepends_ondependsOndependsOndependsOndependsOndependsOnDependsOnDependsOn to confirm first
A transaction's statusstatusstatusstatusstatusstatusstatusStatusStatus is ConfirmationStatus::UnverifiedConfirmationStatus.UNVERIFIEDConfirmationStatus.unverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatus.UnverifiedConfirmationStatusUnverifiedConfirmationStatus.UnverifiedThe chain service was unavailable or rate-limited, so the SDK could not tell whether that step is already on-chainRetry, or use a more reliable chain service (see Customizing the SDK); calling unilateral_exitunilateral_exitunilateralExitunilateralExitunilateralExitunilateralExitunilateralExitUnilateralExitUnilateralExit again re-checks