In the three previous articles of the Bitcoin Transfers series, we explored everything from the basic anatomy of a transaction to more efficient sending strategies, always with a practical emphasis on using Bitcoin Core on test networks. Now we move forward: beyond moving bitcoin correctly and cheaply, it is time to think about how to strengthen the security of every transfer.
This step is essential because, in the Bitcoin ecosystem, protection does not depend only on safeguarding the private key. The concept of defense in depth shows that real security emerges when we build several barriers: from key generation to the very way we structure transactions. Features such as multisig and timelocks allow us to create operations that resist loss, theft, or unauthorized access, offering resilience even in failure scenarios.
In this article, we will see how to apply each of these techniques, combining theory and practice so that your transfers not only happen, but are more robust and versatile. First, let’s understand PSBTs (Partially Signed Bitcoin Transactions), which are fundamental for using these advanced features.
A PSBT is a standard defined in BIP174 that allows transactions to be created, shared, and signed collaboratively.
It clearly separates the stages:
This ensures that no single device needs all the keys or full internet access, which is essential for multisig, hardware wallets, or air-gapped setups.
Basic commands in Bitcoin Core:
| Step | Command |
|---|---|
| Create a PSBT with defined inputs/outputs | walletcreatefundedpsbt |
| Sign partially | walletprocesspsbt |
| Combine several PSBTs (multiple signers) | combinepsbt |
| Finalize (produces broadcastable hex) | finalizepsbt |
| Send | sendrawtransaction |
A PSBT is useful even with a single signer when you separate environments: create the transaction on one machine and sign it on another (secure/offline).
Let’s go through an example in Bitcoin Core.
0) Prerequisites
1) Create the PSBT (selects UTXOs and defines outputs)
bitcoin-cli -datadir="." walletcreatefundedpsbt \
'[]' '[{"tb1q2tygw0rd24cgrrj9dajqrh398clxucnhdwlxew":0.0001}]' \
0 '{"subtractFeeFromOutputs":[0]}' true
{
"psbt": "cHNidP8BAHECAAAAAXT7uaRFVyzfv8vbDqsOc4AWZzxAoX0S...(shortened, same as the original)...AAAAA=",
"fee": 0.00001410,
"changepos": 1
}
2) Sign partially (in the same wallet)
bitcoin-cli -datadir="." walletprocesspsbt "cHNidP8BAHECAAAAAXT7uaRFVyzfv8vbDqsOc4AWZzxAoX0S...(shortened)..."
{
"psbt": "cHNidP8BAHECAAAAAXT7uaRFVyzfv8vbDqsOc4AWZzxAoX0S...(shortened)...",
"complete": true,
"hex": "0200000000010174fbb9a445572cdfbfcbdb0eab0e738016673c...(shortened)...00000000"
}
walletprocesspsbt adds the signatures that the wallet is able to produce. In a regular (non-multisig) wallet, this leaves complete=true.
Explaining the fields the command returns:
psbt
complete were false, this would be the value to send to the next participant or to use in finalizepsbt.complete
true means that all required signatures have already been added.false, you would keep circulating the PSBT among the parties or run combinepsbt, etc.hex
complete is true, Core already hands you this hex, which can be broadcast right away.3) Finalize and broadcast
Since complete = true, there is no need to call finalizepsbt. Just take the hex value and broadcast it:
bitcoin-cli -datadir="." sendrawtransaction "0200000000010174fbb9a445572cdfbfcbdb0eab0e738016673c...(shortened)...00000000"
# a1a93c02cfb449040677950d1abf33f57c923273d5f091c942c08d229e638ce4
If complete = false, we would use the returned "psbt":
bitcoin-cli -datadir="." finalizepsbt "<SIGNED_PSBT>"
This command would produce the final hex to be broadcast.
Multisig is a custody scheme in Bitcoin in which a spend is only valid when a predefined minimum number of keys, among several participants, sign the transaction, such as “2-of-3” or “3-of-5”, increasing security and allowing shared control.
Advantages:
Let’s look at a practical example in Bitcoin Core:
1) Create three wallets (participants)
bitcoin-cli -datadir="." createwallet carteira1 true true "" true true
bitcoin-cli -datadir="." createwallet carteira2 true true "" true true
bitcoin-cli -datadir="." createwallet carteira3 true true "" true true
We create wallets with private keys and with descriptors. Avoid blank=true and disable_private_keys=true so you don’t end up with “empty” wallets.
2) Get the xpubs (with fingerprint/derivation)
For each wallet, list the public descriptors and copy the receiving (…/0/*) and change (…/1/*) lines:
bitcoin-cli -datadir="." -rpcwallet=carteira1 listdescriptors
{
"wallet_name": "carteira1",
"descriptors": [
...,
{
"desc": "wpkh([ba39c771/84h/1h/0h]tpubDCYmREHcMiWLvGJ4goQeYcvNARJtp8Bcuf2wSo5NZKHLHiu91xoH1135ngN3MmHHB9L6bYabHiFkBamEavcVeEoKGDWj7op1atN6xeCotwk/0/*)#jr2a4z9x",
"timestamp": 1758650612,
"active": true,
"internal": false,
"range": [
0,
999
],
"next": 0,
"next_index": 0
},
{
"desc": "wpkh([ba39c771/84h/1h/0h]tpubDCYmREHcMiWLvGJ4goQeYcvNARJtp8Bcuf2wSo5NZKHLHiu91xoH1135ngN3MmHHB9L6bYabHiFkBamEavcVeEoKGDWj7op1atN6xeCotwk/1/*)#rh0ugh47",
"timestamp": 1758650613,
"active": true,
"internal": true,
"range": [
0,
999
],
"next": 0,
"next_index": 0
}
]
}
bitcoin-cli -datadir="." -rpcwallet=carteira2 listdescriptors
{
...,
{
"desc": "wpkh([1b69360a/84h/1h/0h]tpubDD2gr2TXupxDBjtLUnFkWVsNMYNNcPeLrRmR4M9Eo7NEShdCnJRcsccxRtzAKDT1F2cXGdSKPTerBX2FsYRUcDZtRNzd7rPeS9Fg3HMBC4x/0/*)#k32vf2uu",
"timestamp": 1758650639,
"active": true,
"internal": false,
"range": [
0,
999
],
"next": 0,
"next_index": 0
},
{
"desc": "wpkh([1b69360a/84h/1h/0h]tpubDD2gr2TXupxDBjtLUnFkWVsNMYNNcPeLrRmR4M9Eo7NEShdCnJRcsccxRtzAKDT1F2cXGdSKPTerBX2FsYRUcDZtRNzd7rPeS9Fg3HMBC4x/1/*)#890d5lvy",
"timestamp": 1758650640,
"active": true,
"internal": true,
"range": [
0,
999
],
"next": 0,
"next_index": 0
}
]
}
bitcoin-cli -datadir="." -rpcwallet=carteira3 listdescriptors
{
"wallet_name": "carteira3",
"descriptors": [
...,
{
"desc": "wpkh([fd4d4620/84h/1h/0h]tpubDDnWDjzpV82Wxdr7woANyPdKnSvQvEERPnzxoK2iPLUf8M8HA6Dr1rKw5FqnUFabN5zbQjexYNTCW8QC16NUpd6m1JogodyjLe5fRYvQ5KT/0/*)#5szljz27",
"timestamp": 1758650645,
"active": true,
"internal": false,
"range": [
0,
999
],
"next": 0,
"next_index": 0
},
{
"desc": "wpkh([fd4d4620/84h/1h/0h]tpubDDnWDjzpV82Wxdr7woANyPdKnSvQvEERPnzxoK2iPLUf8M8HA6Dr1rKw5FqnUFabN5zbQjexYNTCW8QC16NUpd6m1JogodyjLe5fRYvQ5KT/1/*)#9y870h6x",
"timestamp": 1758650645,
"active": true,
"internal": true,
"range": [
0,
999
],
"next": 0,
"next_index": 0
}
]
}
What to copy: the public descriptors for receiving (…/0/*) and change (…/1/*). They carry the fingerprint + path (e.g., [ba39c771/84h/1h/0h]tpub.../0/*), which is essential for building the multisig correctly.
Address standard: the examples use the 84h/1h/0h derivation (Bech32, P2WPKH for singlesig). In multisig, we will combine them into P2WSH (wsh(sortedmulti(...))).
3) Create the multisig wallet (watch-only) and import the multisig descriptors (receiving and change)
Why “watch-only”? This wallet sees UTXOs, generates addresses, and creates PSBTs, but it has no private keys; the signatures happen in the participant wallets (carteira1, 2, and 3).
bitcoin-cli -datadir="." createwallet multisig-2of3 true true "" true true
{
"name": "multisig-2of3",
"warnings": [
"Empty string given as passphrase, wallet will not be encrypted."
]
}
bitcoin-cli -datadir="." -rpcwallet=multisig-2of3 importdescriptors [{\"desc\":\"wsh(sortedmulti(2,[ba39c771/84h/1h/0h]tpubDCYmREHcMiWLvGJ4goQeYcvNARJtp8Bcuf2wSo5NZKHLHiu91xoH1135ngN3MmHHB9L6bYabHiFkBamEavcVeEoKGDWj7op1atN6xeCotwk/0/*,[1b69360a/84h/1h/0h]tpubDD2gr2TXupxDBjtLUnFkWVsNMYNNcPeLrRmR4M9Eo7NEShdCnJRcsccxRtzAKDT1F2cXGdSKPTerBX2FsYRUcDZtRNzd7rPeS9Fg3HMBC4x/0/*,[fd4d4620/84h/1h/0h]tpubDDnWDjzpV82Wxdr7woANyPdKnSvQvEERPnzxoK2iPLUf8M8HA6Dr1rKw5FqnUFabN5zbQjexYNTCW8QC16NUpd6m1JogodyjLe5fRYvQ5KT/0/*))#ed8gy0hl\",\"active\":true,\"timestamp\":\"now\"},{\"desc\":\"wsh(sortedmulti(2,[ba39c771/84h/1h/0h]tpubDCYmREHcMiWLvGJ4goQeYcvNARJtp8Bcuf2wSo5NZKHLHiu91xoH1135ngN3MmHHB9L6bYabHiFkBamEavcVeEoKGDWj7op1atN6xeCotwk/1/*,[1b69360a/84h/1h/0h]tpubDD2gr2TXupxDBjtLUnFkWVsNMYNNcPeLrRmR4M9Eo7NEShdCnJRcsccxRtzAKDT1F2cXGdSKPTerBX2FsYRUcDZtRNzd7rPeS9Fg3HMBC4x/1/*,[fd4d4620/84h/1h/0h]tpubDDnWDjzpV82Wxdr7woANyPdKnSvQvEERPnzxoK2iPLUf8M8HA6Dr1rKw5FqnUFabN5zbQjexYNTCW8QC16NUpd6m1JogodyjLe5fRYvQ5KT/1/*))#tcmu8qku\",\"active\":true,\"internal\":true,\"timestamp\":\"now\"}]
[
{
"success": true,
"warnings": [
"Range not given, using default keypool range"
]
},
{
"success": true,
"warnings": [
"Range not given, using default keypool range"
]
}
]
sortedmulti: guarantees a deterministic ordering of the keys (regardless of input order), avoiding different addresses for the same set of keys.
The “Range not given” warning only tells you that Core will use the default range 0–999 to generate addresses. For most cases, that is fine.
4) Receive funds into the multisig
Generate a new address for this new multisig wallet:
bitcoin-cli -datadir="." -rpcwallet=multisig-2of3 getnewaddress
# tb1qrlwt49k9hqgj7m3rlc4elfj3fxe96l93zz9ez5209z8uskrqg7yqhl3v88
Send some sats to the generated address (via a signet faucet). Wait for 1+ confirmations.
5) Create the spending PSBT (in the multisig wallet)
bitcoin-cli -datadir="." -rpcwallet=multisig-2of3 walletcreatefundedpsbt "[]" "[{\"tb1qqu7d6zmh5mpf07xd7t9aehe0re63hfqk2wh2ew6045zc5svshphq7j4eek\":0.00001}]" 0 "{\"subtractFeeFromOutputs\":[0]}" true
{
"psbt": "cHNidP8BAIkCAAAAAbALDAmCzaLpmGLM3HosXOuodp03BnzZ...(shortened, same as the original)...AAAAAA==",
"fee": 0.00000201,
"changepos": 0
}
walletcreatefundedpsbt selects UTXOs, builds the transaction, calculates the fee, and returns a PSBT with the metadata needed for signing.
subtractFeeFromOutputs: if set to output index 0, the fee is “deducted” from that output (useful for sending “whatever is left” without adjusting manually).
6) Sign in two wallets (Carteira1 and Carteira2, separately)
bitcoin-cli -datadir="." -rpcwallet=carteira1 walletprocesspsbt "cHNidP8BAIkCAAAAAbALDAmCzaLpmGLM3HosXOuodp03BnzZ...(shortened, the original PSBT from step 5)..."
{
"psbt": "cHNidP8BAIkCAAAAAbALDAmCzaLpmGLM3HosXOuodp03BnzZ...(shortened, PSBT with carteira1's signature)...",
"complete": false
}
bitcoin-cli -datadir="." -rpcwallet=carteira2 walletprocesspsbt "cHNidP8BAIkCAAAAAbALDAmCzaLpmGLM3HosXOuodp03BnzZ...(shortened, the original PSBT from step 5)..."
{
"psbt": "cHNidP8BAIkCAAAAAbALDAmCzaLpmGLM3HosXOuodp03BnzZ...(shortened, PSBT with carteira2's signature)...",
"complete": false
}
Each participant signs with walletprocesspsbt.
Sequential vs. parallel:
psbt already holding 1 signature) goes into carteira2 → it usually comes back with complete: true.7) Combine the 2 signed PSBTs
bitcoin-cli -datadir="." combinepsbt "[\"<PSBT_SIGNED_BY_CARTEIRA1>\",\"<PSBT_SIGNED_BY_CARTEIRA2>\"]"
# cHNidP8BAIkCAAAAAbALDAmCzaLpmGLM3HosXOuodp03BnzZ...(shortened, combined PSBT)...
When to use it: if the signers signed in parallel (each took the original PSBT).
Expected result: the combined PSBT should already contain the 2 required signatures (2-of-3).
8) Finalize and broadcast
bitcoin-cli -datadir="." finalizepsbt "<COMBINED_PSBT>"
{
"hex": "02000000000101b00b0c0982cda2e99862ccdc7a2c5ceba8769d37067cd9...(shortened)...53ae00000000",
"complete": true
}
finalizepsbt: validates the script, assembles the witness, and returns the hex ready for broadcast.
Finally, the sendrawtransaction command publishes the transaction to the network.
bitcoin-cli -datadir="." sendrawtransaction "02000000000101b00b0c0982cda2e99862ccdc7a2c5ceba8769d37067cd9...(shortened)...53ae00000000"
# af5f0813b622d6a21389af6cecbe9d5098d71cdee814e7ba70fcbe0a7d1ce407
Notice, in the mempool, that this is a 2-of-3 multisig transaction:

In Bitcoin, timelock is the generic name for mechanisms that determine when a transaction or a given UTXO can be spent. Instead of merely requiring a valid signature, the protocol also checks time or block height, creating “mandatory waiting” conditions.
There are two main forms.
There is also the CheckSequenceVerify (CSV) variant, which works similarly but is based on a relative interval of confirmations (e.g., “can be spent 144 blocks after this UTXO is confirmed”).
These features make it possible to create contracts such as vaults with a recovery period, inheritances with future unlocking, or scheduled payments that only become valid after a certain date.
a) nLockTime in practice Goal: create a transaction that can only be mined after a target block.
bitcoin-cli -datadir="." getblockcount
# 271141
The getblockcount command returns the current blockchain height. We can choose, for example, that the transaction can only be included from block 271145.
bitcoin-cli -datadir="." -rpcwallet="demo-signet" createrawtransaction "[]" "{\"tb1q4asvsg6hhv6vwxzq5uy05wf6zzusp8me5xmlzv\":0.0001}" 271145
0200000000011027000000000000160014af60c82357bb34c71840a708fa393a10b9009f7929230400
bitcoin-cli -datadir="." -rpcwallet=demo-signet fundrawtransaction "0200000000011027000000000000160014af60c82357bb34c71840a708fa393a10b9009f7929230400"
{
"hex": "0200000001e48c639e228dc042c991f0d57332927cf533bf1a0d9577060449b4cf023ca9a10000000000fdffffff021027000000000000160014af60c82357bb34c71840a708fa393a10b9009f7961320100000000001600143c7fa3189d4e5c9e6ca96eba73a525c94bf8ff9c29230400",
"fee": 0.00000141,
"changepos": 1
}
bitcoin-cli -datadir="." -rpcwallet="demo-signet" signrawtransactionwithwallet "0200000001e48c639e228dc042c991f0d57332927cf533bf1a0d9577060449b4cf023ca9a10000000000fdffffff021027000000000000160014af60c82357bb34c71840a708fa393a10b9009f7961320100000000001600143c7fa3189d4e5c9e6ca96eba73a525c94bf8ff9c29230400"
{
"hex": "02000000000101e48c639e228dc042c991f0d57332927cf533bf1a0d9577060449b4cf023ca9a10000000000fdffffff021027000000000000160014af60c82357bb34c71840a708fa393a10b9009f7961320100000000001600143c7fa3189d4e5c9e6ca96eba73a525c94bf8ff9c0247304402205e358ba9097c1f7efe67093fae52c4664cf17648f75185ae47a95a875db8fccb0220691d79b3eb4dc54b3c095e167340f084057b1f803f0375460ecfdc4c4af2dfa1012102ce170948697b4e2d96f3c85be5841c99a5deb2318855f9f041f23db9af7f62e829230400",
"complete": true
}
Notice that if we try to broadcast the transaction before the defined block height, we get an error:
bitcoin-cli -datadir="." getblockcount
271143
bitcoin-cli -datadir="." sendrawtransaction "02000000000101e48c639e228dc042c991f0d57332927cf533bf1a0d9577060449b4cf023ca9a10000000000fdffffff021027000000000000160014af60c82357bb34c71840a708fa393a10b9009f7961320100000000001600143c7fa3189d4e5c9e6ca96eba73a525c94bf8ff9c0247304402205e358ba9097c1f7efe67093fae52c4664cf17648f75185ae47a95a875db8fccb0220691d79b3eb4dc54b3c095e167340f084057b1f803f0375460ecfdc4c4af2dfa1012102ce170948697b4e2d96f3c85be5841c99a5deb2318855f9f041f23db9af7f62e829230400"
error code: -26
error message:
non-final
In Bitcoin, the nLockTime field works only as a block validity rule, not as an automatic scheduler. It defines the lowest block height (or timestamp) at which the transaction can be included in the blockchain, but the network does not “hold” it until then. If you try to broadcast it before the limit is reached, nodes reject it with the non-final error and nothing stays pending. That is why the software or script that creates the transaction must track the current height (for example, via getblockcount) and only broadcast when current_height is greater than or equal to the nLockTime value. This differs from a script timelock (such as OP_CHECKLOCKTIMEVERIFY), which writes the restriction into the output and lets you send the funding transaction today, guaranteeing it can only be spent after the deadline, without depending on external monitoring.
When we send it after the target, it works:
bitcoin-cli -datadir="." getblockcount
271145
bitcoin-cli -datadir="." sendrawtransaction "02000000000101e48c639e228dc042c991f0d57332927cf533bf1a0d9577060449b4cf023ca9a10000000000fdffffff021027000000000000160014af60c82357bb34c71840a708fa393a10b9009f7961320100000000001600143c7fa3189d4e5c9e6ca96eba73a525c94bf8ff9c0247304402205e358ba9097c1f7efe67093fae52c4664cf17648f75185ae47a95a875db8fccb0220691d79b3eb4dc54b3c095e167340f084057b1f803f0375460ecfdc4c4af2dfa1012102ce170948697b4e2d96f3c85be5841c99a5deb2318855f9f041f23db9af7f62e829230400"
# 2c91cbd4e8bccacb42020eb88a1df0816d99fae7e2ee12ff6a8c996ca282374b
b) CLTV with Miniscript (a “strong” timelock on the UTXO)
Goal: create a P2WSH address whose script requires a minimum block height to spend.
Let’s go step by step.
We will create a new wallet so as not to mix it with other examples.
bitcoin-cli -datadir="." createwallet carteira_cltv
{
"name": "carteira_cltv"
}
With the new wallet, we can generate a new address.
bitcoin-cli -datadir="." -rpcwallet="carteira_cltv" getnewaddress
# tb1q84ysz0zng7m8yad6zrps3uyn6reu977v85x0ga
Next, we can get the information for this new address (we will use it below):
bitcoin-cli -datadir="." -rpcwallet="carteira_cltv" getaddressinfo tb1q84ysz0zng7m8yad6zrps3uyn6reu977v85x0ga
{
"address": "tb1q84ysz0zng7m8yad6zrps3uyn6reu977v85x0ga",
"scriptPubKey": "00143d49013c5347b67275ba10c308f093d0f3c2fbcc",
"ismine": true,
"solvable": true,
"desc": "wpkh([261430f9/84h/1h/0h/0/0]03a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79)#l4tsyqsx",
"parent_desc": "wpkh([261430f9/84h/1h/0h]tpubDDEKB1hKK8aCiLLPE35AqyEnRSbRTWgo27VVfG1iF8mdsJnYvuss736NutifM2NWnH78AVjW4hvHaDg3CzW7ZAHXVBMgPvoiYuYrZ5RgJii/0/*)#2hke87n4",
"iswatchonly": false,
"isscript": false,
"iswitness": true,
"witness_version": 0,
"witness_program": "3d49013c5347b67275ba10c308f093d0f3c2fbcc",
"pubkey": "03a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79",
"ischange": false,
"timestamp": 1758845787,
"hdkeypath": "m/84h/1h/0h/0/0",
"hdseedid": "0000000000000000000000000000000000000000",
"hdmasterfingerprint": "261430f9",
"labels": [
""
]
}
From the pubkey, we can build a new, specific descriptor that produces addresses with a block-height restriction (after(271220)):
bitcoin-cli -datadir="." -rpcwallet="carteira_cltv" getdescriptorinfo "wsh(and_v(v:pk(03a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79),after(271220)))"
{
"descriptor": "wsh(and_v(v:pk(03a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79),after(271220)))#gk4hzcry",
"checksum": "gk4hzcry",
"isrange": false,
"issolvable": true,
"hasprivatekeys": false
}
This command returns a descriptor already with its #CHECKSUM (in the example, #gk4hzcry).
With the descriptor above, we can derive addresses that already carry the block-height restriction in their script.
bitcoin-cli -datadir="." -rpcwallet="carteira_cltv" deriveaddresses "wsh(and_v(v:pk(03a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79),after(271220)))#gk4hzcry"
[
"tb1qjtt0qckzytswg2fa25x7ru6unnulhttd0jx82z7jf8epv95wkqwqcq3ave"
]
Any UTXO associated with the address tb1qjtt0qckzytswg2fa25x7ru6unnulhttd0jx82z7jf8epv95wkqwqcq3ave can only be spent from block 271220 onward.
We can check the current block:
bitcoin-cli -datadir="." getblockcount
271211
We can send some funds to this newly created address so that we can then test whether we are able to make transactions before the defined block height. We can use a faucet or send BTC from another wallet, as in the example below:
bitcoin-cli -datadir="." -rpcwallet="demo-signet" sendtoaddress "tb1qjtt0qckzytswg2fa25x7ru6unnulhttd0jx82z7jf8epv95wkqwqcq3ave" 0.0001
# 8cc8b2a47bff4a4b146f16ca2656bfb341bb95040ace91b4d6698e3c34d00c3a
In fact, that is all there is to using a script timelock (CLTV); it is already in place. The next steps only serve to demonstrate that we will need to wait until the specific block height to be able to spend this UTXO.
Remember that, when Bitcoin Core creates a wallet, it creates it with those 8 default descriptors. In the previous steps, we generated a new address for the carteira_cltv wallet from a new descriptor. However, by default Core does not automatically associate all UTXOs of this new descriptor with the wallet. Therefore, if we run listunspent on the carteira_cltv wallet, it will not show the UTXO from the transaction above (the one that funded the address). To fix this, we will create a new watch-only wallet:
bitcoin-cli -datadir="." createwallet carteira_cltv_wo true
{
"name": "carteira_cltv_wo"
}
And right after, we will use the importdescriptors command so that this new wallet sees the UTXOs associated with the addresses generated by the new descriptor:
bitcoin-cli -datadir="." -rpcwallet=carteira_cltv_wo importdescriptors "[{\"desc\":\"wsh(and_v(v:pk(03a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79),after(271220)))#gk4hzcry\",\"timestamp\":0,\"label\":\"cltv-271220\"}]"
[
{
"success": true
}
]
This command may take a few minutes, since it scans all existing UTXOs.
Once the transaction that sent funds to this address is confirmed, we will be able to see the UTXO:
bitcoin-cli -datadir="." -rpcwallet=carteira_cltv_wo listunspent
[
{
"txid": "8cc8b2a47bff4a4b146f16ca2656bfb341bb95040ace91b4d6698e3c34d00c3a",
"vout": 1,
"address": "tb1qjtt0qckzytswg2fa25x7ru6unnulhttd0jx82z7jf8epv95wkqwqcq3ave",
"label": "cltv-271220",
"witnessScript": "2103a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79ad03742304b1",
"scriptPubKey": "002092d6f062c222e0e4293d550de1f35c9cf9fbad6d7c8c750bd249f216168eb01c",
"amount": 0.00010000,
"confirmations": 1,
"spendable": true,
"solvable": true,
"desc": "wsh(and_v(v:pk([3d49013c]03a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79),after(271220)))#dz9nuz04",
"parent_descs": [
"wsh(and_v(v:pk(03a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79),after(271220)))#gk4hzcry"
],
"safe": true
}
]
Now let’s create a PSBT transaction to try to spend the locked UTXO.
bitcoin-cli -datadir="." -rpcwallet=carteira_cltv_wo createpsbt "[{\"txid\":\"8cc8b2a47bff4a4b146f16ca2656bfb341bb95040ace91b4d6698e3c34d00c3a\",\"vout\":1,\"sequence\":4294967293}]" "[{\"tb1q3hhxstxlemumvsw2v8cgxwf860f6hxyk7p0d6v\":0.00009800}]" 271220
cHNidP8BAFICAAAAAToM0DQ8jmnWtJHOCgSVu0Gzv1YmyhZvFEtK/3ukssiMAQAAAAD9////AUgmAAAAAAAAFgAUje5oLN/O+bZBymHwgzkn09OrmJZ0IwQAAAAA
To be able to spend, I need to build a transaction in the expected format, so that the unlocking script is correct. Thus, we need to create the PSBT with the transaction’s TXID (with its vout), sequence, the destination address, the amount, and the unlock block height.
Each input of a transaction has a Sequence field.
0xffffffff), the transaction’s nLockTime is ignored.0xfffffffd), nLockTime takes effect.In our case, the real timelock is in the UTXO’s script (OP_CHECKLOCKTIMEVERIFY generated by after(...)). nSequence only ensures that the transaction respects this timelock; it cannot “unlock” the UTXO early. Even if someone tries to change nSequence to get around it, the network will keep requiring that the minimum block height be reached before spending.
bitcoin-cli -datadir="." -rpcwallet=carteira_cltv_wo walletprocesspsbt "cHNidP8BAFICAAAAAToM0DQ8jmnWtJHOCgSVu0Gzv1YmyhZvFEtK/3ukssiMAQAAAAD9////AUgmAAAAAAAAFgAUje5oLN/O+bZBymHwgzkn09OrmJZ0IwQAAAAA" false
{
"psbt": "cHNidP8BAFICAAAAAToM0DQ8jmnWtJHOCgSVu0Gzv1YmyhZvFEtK/3ukssiM...(shortened)...",
"complete": false
}
We complete/sign the PSBT transaction with the watch-only wallet. And then with the original wallet (which has the private keys):
bitcoin-cli -datadir="." -rpcwallet=carteira_cltv walletprocesspsbt "cHNidP8BAFICAAAAAToM0DQ8jmnWtJHOCgSVu0Gzv1YmyhZvFEtK/3ukssiM...(shortened)..." true
{
"psbt": "cHNidP8BAFICAAAAAToM0DQ8jmnWtJHOCgSVu0Gzv1YmyhZvFEtK/3ukssiM...(shortened)...",
"complete": true,
"hex": "020000000001013a0cd0343c8e69d6b491ce0a0495bb41b3bf5626ca166f144b4aff7ba4b2c88c0100000000fdffffff0148260000000000001600148dee682cdfcef9b641ca61f0833927d3d3ab9896024730440220708bf414673d06837024b38300527ed89243504e98fb4645b76beb8c9f8d6298022010185c548463c3fabf475dc758e620dc46b83d6fe28fe6c34b80cf10f3dd72fb01282103a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79ad03742304b174230400"
}
We finalize the PSBT:
bitcoin-cli -datadir="." finalizepsbt "cHNidP8BAFICAAAAAToM0DQ8jmnWtJHOCgSVu0Gzv1YmyhZvFEtK/3ukssiM...(shortened)..."
{
"hex": "020000000001013a0cd0343c8e69d6b491ce0a0495bb41b3bf5626ca166f144b4aff7ba4b2c88c0100000000fdffffff0148260000000000001600148dee682cdfcef9b641ca61f0833927d3d3ab9896024730440220708bf414673d06837024b38300527ed89243504e98fb4645b76beb8c9f8d6298022010185c548463c3fabf475dc758e620dc46b83d6fe28fe6c34b80cf10f3dd72fb01282103a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79ad03742304b174230400",
"complete": true
}
And we send it:
bitcoin-cli -datadir="." sendrawtransaction "020000000001013a0cd0343c8e69d6b491ce0a0495bb41b3bf5626ca166f144b4aff7ba4b2c88c0100000000fdffffff0148260000000000001600148dee682cdfcef9b641ca61f0833927d3d3ab9896024730440220708bf414673d06837024b38300527ed89243504e98fb4645b76beb8c9f8d6298022010185c548463c3fabf475dc758e620dc46b83d6fe28fe6c34b80cf10f3dd72fb01282103a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79ad03742304b174230400"
error code: -26
error message:
non-final
Notice that it errors out. The non-final message indicates that this transaction cannot yet be accepted into the mempool.
We wait for the block height to pass 271220 and send it again:
bitcoin-cli -datadir="." getblockcount
271227
bitcoin-cli -datadir="." sendrawtransaction "020000000001013a0cd0343c8e69d6b491ce0a0495bb41b3bf5626ca166f144b4aff7ba4b2c88c0100000000fdffffff0148260000000000001600148dee682cdfcef9b641ca61f0833927d3d3ab9896024730440220708bf414673d06837024b38300527ed89243504e98fb4645b76beb8c9f8d6298022010185c548463c3fabf475dc758e620dc46b83d6fe28fe6c34b80cf10f3dd72fb01282103a63fe04543a8e2ca0194ba811ae076eb350c0708f5e08925db020a59bbbecf79ad03742304b174230400"
e2b64a372d5bab18658f5ce14bc27cd6c502c7026b8bc5853462c254638603b1
Throughout this article, we saw how to move beyond the basic “send and sign” to build well-structured and more secure transactions in Bitcoin Core.
PSBT separates responsibilities (create, sign, finalize), enabling collaborative workflows and offline environments; multisig (m-of-n) adds resilience against key loss and compromise; and timelocks, both nLockTime (in the transaction) and CLTV/CSV (in the script, via Miniscript), let you enforce when a UTXO can be spent. Together, these features form complementary layers: key policy (multisig), time policy (timelocks), and a secure transport format (PSBT). The result is a sending process that is auditable, reproducible, and resistant to operational failures, ready to evolve from the signet lab to professional day-to-day practice.