Bitcoin Taproot Puzzles
In 2013, Peter Todd published a few transactions with puzzles in them. The trick was to find hashing algorithm collisions. People who found the collision were able to spend the bitcoin locked with those hashes.
When I saw this, I was like… I can’t even make a single transaction from scratch programmatically.
This guide will help you create your very own puzzles or participate in any hackathon with these puzzles.
WARNING: spending UTXOs without a signature is highly risky, and attackers will most likely steal your funds if using the Bitcoin main net.
For this guide, I’m using testnet4.
For simplicity, I use a very simple Script and not a complicated one like Todd’s. Here is the ASM script I will be using for this tutorial:
OP_SHA1 <HASH> OP_EQUALFirst, if you haven’t already learn how to setup your environment, how to create your wallet addresses, how to fund your address, or how to publish transactions, head over to my Bitcoin Taproot Hello World turorial:
Fund your taproot address
Create and fund a new taproot address and store the funding transaction id because you will need it to spend the Bitcoin you received from the faucet. For this tutorial, I use the following:
Mnemonic: jaguar debate issue visit diamond recipe pyramid bulb diet electric immense shaft
Taproot Address: tb1peextn6a2n9d2c0s0u5jwrflr4fusmvnlnm70hchs4j006ml3m56qdqseu8
Funding Transaction: 1b186fd9af04f0c71e7e3c5ad0a6fe6265f258ee78bc6ade9918fe6ff57e330fCreate SHA1 hack-locked script
For this tutorial, I’m going to use hello puzzle as the secret:
const preimage = Buffer.from('hello puzzle');
const sha1Hash = crypto.createHash('sha1').update(preimage).digest();
const sha1Script = bitcoin.script.compile([
bitcoin.opcodes.OP_SHA1,
sha1Hash,
bitcoin.opcodes.OP_EQUAL,
]);Now, when creating the taproot output p2tr address, you will include this script as follows:
// Taproot leaf version (currently the only supported version)
const leafVersion = 0xc0;
// Create Taproot script tree
const scriptTree = {
output: sha1Script,
version: leafVersion, // Default leaf version for TapScript
};
// Create Taproot output with script tree
const taprootOutput = bitcoin.payments.p2tr({
internalPubkey,
scriptTree,
network,
});Now use the created p2tr address in out of the outputs, and I’m going to use 1,000 sats as the reward for whoever unlocks the puzzle
// Add Taproot output (lock funds with SHA1 script)
const lockAmount = 1000n;
psbt.addOutput({
address: taprootOutput.address,
value: lockAmount,
});The rest of the code in Hello World is the same.
When I run my code, I get the following transaction hex
020000000001010f337ef56ffe1899de6abc78ee58f26562fea6d05a3c7e1ec7f004afd96f181b0100000000fdffffff02e803000000000000225120042e9b5eabfa28eae55793a3bbc740ccb8173220584d3fb8bc88acdcbfe2d827000f000000000000225120c64937cf3b01d1558f87a89a4bd8f60c764f7823e3916899663fdf986021466f014047c72e2019eeb85d1549632ff00519427ed6d3b8067163a963bd94eda2ca763ed0e135ae5254cbcd04bb1e04a7ec3fe86707702c849d8add9e2c00e84c1eb5eb00000000I test it:
and then broadcast it:
As you can see, one of the outputs has 1,000 sats, that’s where we will need out puzzle to unlock the fund. Copy the transaction ID, you’re going to need it to spend the UTXO with the puzzle
Unlock funds with the puzzle
This is where the players try, and try, and try to find the preimage with the hash you give them. In this case, the SHA1 hash for our “secret” preimage is:
098f9ed44ff560c7d8529068fde96911b6262ff9For someone to spend the locked sats, they need:
- Locking Transaction ID, in our case:
TxID: 0934fc3c17f42e1c77265cb0345ab3ac3f9e3d40c670c62f0ba0aaa6f8bf8e9c2. ScriptPubKey (HEX) can be found on an explorer:
ScriptPubKey: 5120042e9b5eabfa28eae55793a3bbc740ccb8173220584d3fb8bc88acdcbfe2d8273. The Unlocking ASM Script to recreate the script, or hex, for simplicity, let’s use the hex:
a714098f9ed44ff560c7d8529068fde96911b6262ff9874. The Control Block: (hex):
c020a1a7dc240dd0de656aa7aa4b6b758c22de21db7b91a2570b014cbc4d72c9d4Let’s put it all together:
const p2tr_output_buffer = Buffer.from('5120042e9b5eabfa28eae55793a3bbc740ccb8173220584d3fb8bc88acdcbfe2d827', 'hex');
const sha1Script_buffer = Buffer.from('a714098f9ed44ff560c7d8529068fde96911b6262ff987', 'hex');
const controlBlock_buffer = Buffer.from('c020a1a7dc240dd0de656aa7aa4b6b758c22de21db7b91a2570b014cbc4d72c9d4', 'hex');
// Create PSBT
const psbt = new bitcoin.Psbt({ network });
psbt.addInput({
hash: lockingTxId,
index: lockingIndex,
witnessUtxo: {
script: p2tr_output_buffer,
value: lockedAmount,
},
tapLeafScript: [
{
leafVersion: leafVersion,
script: sha1Script_buffer,
controlBlock: controlBlock_buffer,
},
],
});
psbt.addOutput({
address: dest.address,
value: lockedAmount - fee,
});Finalize the psbt transaction:
// Provide witness stack
psbt.finalizeInput(0, () => ({
finalScriptSig: undefined,
finalScriptWitness: witnessStackToScriptWitness([
preimage,
sha1Script_buffer,
controlBlock_buffer,
]),
}));
// Final TX
const tx = psbt.extractTransaction();In the above script, preimage if the preimage the player is iterating, the solution is:
const preimage = Buffer.from('hello puzzle');After running the code, I get transaction hex:
020000000001019c8ebff8a6aaa00b2fc670c6403d9e3facb35a34b05c26771c2ef4173cfc34090000000000ffffffff014803000000000000225120ce4cb9ebaa995aac3e0fe524e1a7e3aa790db27f9efcfbe2f0ac9efd6ff1dd34030c68656c6c6f2070757a7a6c6517a714098f9ed44ff560c7d8529068fde96911b6262ff98721c020a1a7dc240dd0de656aa7aa4b6b758c22de21db7b91a2570b014cbc4d72c9d400000000And I broadcast the transaction:
Below you can see the broadcasted transaction, and also see a red unlock icon, meaning that it is not signed, and risk losing our funds, but because this is testnet4, nobody is trying to steal fake money:
Spending funds via Key Path
There is another way to spend the funds, Taproot allows spending UTXO in multiple ways.
Remember that when creating the p2tr we also include our public key, well, this allows us to spend the funds without solving the puzzle, but just by signing the input as follows:
const taprootKeyPair = ECPair.fromPrivateKey(child.privateKey, { network: network });
// Internal public key (x-only, 32 bytes)
const internalPubkey2 = taprootKeyPair.publicKey.slice(1, 33);
// Create p2tr payment with full tapleaf script tree and redeem
const p2tr2 = bitcoin.payments.p2tr({
internalPubkey: internalPubkey2,
scriptTree: {
output: sha1Script,
version: leafVersion
},
network,
});
const psbt2 = new bitcoin.Psbt({ network });
psbt2.addInput({
hash: lockingTxId,
index: lockingIndex,
witnessUtxo: {
script: p2tr2.output,
value: lockedAmount,
},
tapInternalKey: internalPubkey2,
sequence: 0xfffffffd, // Enable RBF
});
psbt2.addOutput({
address: dest.address,
value: lockedAmount - fee,
});
// Sign the input
const tweakedChildNode = taprootKeyPair.tweak(
Buffer.from(
bitcoin.crypto.taggedHash(
'TapTweak',
Buffer.concat([
internalPubkey2,
bitcoin.crypto.taggedHash('TapLeaf', Buffer.concat([
Buffer.from([leafVersion]), // Leaf version
Buffer.from([sha1Script.length]), // Script length
sha1Script,
])),
])
)
)
);
psbt2.signInput(0, tweakedChildNode);
// Provide witness stack
psbt2.finalizeAllInputs();
// Final TX
const tx2 = psbt2.extractTransaction();
console.log('Keyspend - Transaction Hex:', tx2.toHex());This is useful if you want to cancel the game.
Conclusion
You learned how to create a UTXO spendable with a puzzle (script) or Key Path, and also learned how to spend it.
Source Code: https://github.com/ObjSal/btc-puzzle
Much more complicated than the Hello World tutorial. Feel free to write a question below, and I will try my best to answer it.
Good Luck!
