What's in a Script?

What's in a Script?

Beau Rancourt
Beau Rancourt

UTXOs, Bitcoin Script, and tBTC v2 deposits explained

TL;DR

How minting tBTC works. The highlighted steps are the focus area for this blog.

In tBTC v2, we use Bitcoin Script to ensure depositors can always get a refund in case of catastrophic wallet error. For each deposit, the tBTC dApp generates a Pay To Script Hash (P2SH) address — a type of Bitcoin address that can only be unlocked when certain conditions are met.

Here's the Script that the dApp used to generate each deposit address:

<eth-address> DROP
<blinding-factor> DROP
DUP HASH160 <signingGroupPubkeyHash> EQUAL
IF
  CHECKSIG
ELSE
  DUP HASH160 <refundPubkeyHash> EQUALVERIFY
  <locktime> CHECKLOCKTIMEVERIFY DROP
  CHECKSIG
ENDIF

Where:

  • <eth-address> is the Ethereum address you'd like your tBTC minted to.
  • <blinding-factor> is 8 bytes of random noise to make sure deposits from the same address to the same address are unique.
  • <signingGroupPubkeyHash> is the public key hash of the currently active tBTC wallet. tBTC wallets are 51-of-100 threshold ECDSA "multisigs" that custody user funds.
  • <refundPubkeyHash> is the public key hash that you'd like to be able to refund the deposit to in case something is amiss.
  • <locktime> is the point in time when you're able to refund the deposit.

In english it reads as "Tell Bitcoin about my ethereum address and blinding factor, but ignore them for the purposes of running the script. The signing group can always unlock the funds. The refund wallet can unlock the funds after <locktime>."

Ownership in Bitcoin

In case the above looks like wizardry, let's take a step back and start from the beginning: Bitcoin's model of ownership.

Bitcoin, unlike an account-based chain like Ethereum, has a model of ownership based on Unspent Transaction Outputs (UTXO). I've always thought of these like working with cash. Rather than having a digital balance associated with your bank account that goes up and down (like in traditional digital banking), you keep track of each coin.

Rather than providing philosophical or legal definitions of ownership like the US Government does, Bitcoin takes a very simple route. In Bitcoin, you can either unlock and relock a particular UTXO. The scripting language for Bitcoin is stack based [note 1] and uses lists of commands that run left-to-right. A UTXO always has a locking script, and then someone provides an unlocking script and a new locking script. We prepend the unlocking script to the original locking script, and evaluate it. If it comes out to be just 1, then we re-lock the UTXO with the provided locking script.

Here's a very simple example:

original lock: [5 9 ADD EQUAL]

then someone might provide:

unlock: [14]
relock: [DUP <publicKey> EQUALVERIFY CHECKSIG]

We prepend the unlocking script to the original lock and get [14 5 9 ADD EQUAL], and then execute this. Here's what the execution steps look like:

(1)
  stack: []
  script: [14 5 9 ADD EQUAL]
  // data is pushed to the end of the stack

(2)
  stack: [14]
  script [5 9 ADD EQUAL]

(3)
  stack: [14 5]
  script [9 ADD EQUAL]

(4)
  stack: [14 5 9]
  script [ADD EQUAL]
  // ADD takes the top two off the stack, adds them, and
    puts the result at the end of the stack

(5)
  stack: [14 14]
  script [EQUAL]
  // EQUAL takes the top two off the stack, then pushes 1 if
    they're equal, else 0 at the end of the stack

(6)
  stack: [1]
  script [] 

Since the stack is just [1] at the end, we re-lock with the provided script: [DUP <publicKey> EQUALVERIFY CHECKSIG].

So who "owns", that Bitcoin? No one and everyone, sort of. Anyone can re-lock it by providing a trivially easy to calculate unlocking script.

What Is My Bitcoin Wallet Doing?

At this point you might interject and say "hang on a second, I have a Bitcoin wallet and it tells me my balance just like a bank. Is that wrong?" Yes, but it's practical. Wallet software is programmed to inspect the Bitcoin chain for particular patterns of locking scripts. Here's an example: [DUP <publicKey> EQUALVERIFY CHECKSIG]. The associated unlocking script is [<signature>, <publicKey>]. Here's how it works:

(1)
  stack: []
  script: [<signature> <publicKey> DUP <publicKey>
    EQUALVERIFY CHECKSIG]
  // data is pushed to the end of the stack

(2)
  stack: [<signature>]
  script: [<publicKey> DUP <publicKey> EQUALVERIFY CHECKSIG] 

(3)
  stack: [<signature> <publicKey>]
  script: [DUP <publicKey> EQUALVERIFY CHECKSIG]
  // DUP adds a copy of the end of the stack

(3)
  stack: [<signature> <publicKey> <publicKey>]
  script: [<publicKey> EQUALVERIFY CHECKSIG] 

(4)
  stack: [<signature> <publicKey> <publicKey> <publicKey>]
  script: [EQUALVERIFY CHECKSIG]
  // EQUALVERIFY removes the top two items of the stack and
    throws an error if they aren't equal

(5)
  stack: [<signature> <publicKey>]
  script: [CHECKSIG] 
  // CHECKSIG removes the top two items of the stack and
    pushes 1 if the first item is a signature that matches
    the second.

(6)
  stack: [1]
  script: [] 

The idea here is that we're only able to create <signature> if we have the private key associated to <publicKey>. There are two checks here: if your signature and public key don't match, the CHECKSIG fails. If your public key doesn't match the one in the locking script, the EQUALVERIFY fails. If both of those passed, you have the private key associated to the public key listed in the unlocking script.

Bitcoin wallets figure out what your <publicKey> is, and then go look for all the UTXOs with unlocking scripts that are exactly [DUP <publicKey> EQUALVERIFY CHECKSIG] (as well as some other common variations).

If someone were to lock up bitcoin using this script: [10 DROP DUP <publicKey> EQUALVERIFY CHECKSIG], then you could unlock that with the same unlocking script, but your wallet won't pick it up or add it to your balance. Demonstration:

(1)
  stack: []
  script: [<signature> <publicKey> 10 DROP DUP <publicKey>
    EQUALVERIFY CHECKSIG]

(2)
  stack: [<signature>]
  script: [<publicKey> 10 DROP DUP <publicKey> EQUALVERIFY
    CHECKSIG]

(3)
  stack: [<signature> <publicKey>]
  script: [10 DROP DUP <publicKey> EQUALVERIFY CHECKSIG]

(4)
  stack: [<signature> <publicKey> 10]
  script: [DROP DUP <publicKey> EQUALVERIFY CHECKSIG]
  // DROP removes the last thing on the stack

(5)
  stack: [<signature> <publicKey>]
  script: [DUP <publicKey> EQUALVERIFY CHECKSIG] 
  // DUP adds a copy of the end of the stack

(6)
  stack: [<signature> <publicKey> <publicKey>]
  script: [<publicKey> EQUALVERIFY CHECKSIG] 

(7)
  stack: [<signature> <publicKey> <publicKey> <publicKey>]
  script: [EQUALVERIFY CHECKSIG]
  // EQUALVERIFY removes the top two items of the stack and
    throws an error if they aren't equal

(8)
  stack: [<signature> <publicKey>]
  script: [CHECKSIG] 
  // CHECKSIG removes the top two items of the stack and
    pushes 1 if the first item is a signature that matches
    the second.

(9)
  stack: [1]
  script: [] 

When we say "I sent someone bitcoin". We mean "I unlocked some bitcoin and then re-locked it using a standard locking script pattern that only they can unlock".

Pay to Script Hash

Final complication! In 2012, Bitcoin approved BIP-0016. The way this works is we create a standardized locking script that looks like [HASH160 <lockingScriptHash> EQUAL].

Then, we provide an unlocking script that looks like [<unlockingScript> <lockingScript>]. Execution:

(1)
  stack: []
  script: [<unlockingScript> <lockingScript> HASH160
    <lockingScriptHash> EQUAL]

(2)
  stack: [<unlockingScript>]
  script: [<lockingScript> HASH160 <lockingScriptHash>
    EQUAL]

(3)
  stack: [<unlockingScript> <lockingScript>]
  script: [HASH160 <lockingScriptHash> EQUAL]
  // HASH160 takes the last item from the stack, hashes it,
    and pushes the result

(4)
  stack: [<unlockingScript> <lockingScriptHash>]
  script: [<lockingScriptHash> EQUAL] 

(5)
  stack: [<unlockingScript> <lockingScriptHash>
    <lockingScriptHash>]
  script: [EQUAL] 
  // EQUAL takes the top two off the stack, then pushes 1 if
    they're equal, else 0 at the end of the stack

(6)
  stack: [<unlockingScript> 1]
  script: [] 

For P2SH scripts specifically, this part succeeds if we're left with [<unlockingscript> 1]. Then, we rewind and make sure that the provided input [<lockingScript> <unlockingScript>] also resolves to [1] when executed as a script. If both parts pass, then we're allowed to relock the UTXO.

Here's a straightforward example that transforms our first script [5 9 ADD EQUAL] into Pay To Script Hash. Say that [5 9 ADD EQUAL] hashes into 3cf7fabccdc56b241f669e1661dd105d75bcefda [note 2]. Then, our locking script would be [HASH160 <3cf7fabccdc56b241f669e1661dd105d75bcefda> EQUAL]. To unlock this we provide: <[14]> <[5 9 ADD EQUAL]> as the unlocking script. Execution:

(1)
  stack: []
  script: [<[14]> <[5 9 ADD EQUAL]> HASH160
    <3cf7fabccdc56b241f669e1661dd105d75bcefda> EQUAL]

(2)
  stack: [<[14]>]
  script: [<[5 9 ADD EQUAL]> HASH160
    <3cf7fabccdc56b241f669e1661dd105d75bcefda> EQUAL]

(3)
  stack: [<[14]> <[5 9 ADD EQUAL]>]
  script: [HASH160
    <3cf7fabccdc56b241f669e1661dd105d75bcefda> EQUAL]
  // HASH160 takes the last item from the stack, hashes it,
    and pushes the result

(4)
  stack: [<[14]> <3cf7fabccdc56b241f669e1661dd105d75bcefda>]
  script: [<3cf7fabccdc56b241f669e1661dd105d75bcefda> EQUAL] 

(5)
  stack: [<[14]> <3cf7fabccdc56b241f669e1661dd105d75bcefda>
    <3cf7fabccdc56b241f669e1661dd105d75bcefda>]
  script: [EQUAL] 
  // EQUAL takes the top two off the stack, then pushes 1 if
    they're equal, else 0 at the end of the stack

(6)
  stack: [<[14]> 1]
  script: [] 

the first part is successful

(7)
  stack: []
  script: [14 5 9 ADD EQUAL]
  // data is pushed to the end of the stack

(8)
  stack: [14]
  script [5 9 ADD EQUAL] 

(9)
  stack: [14 5]
  script [9 ADD EQUAL]

(10)
  stack: [14 5 9]
  script [ADD EQUAL]
  // ADD takes the top two off the stack, adds them, and
    puts the result at the end of the stack

(11)
  stack: [14 14]
  script [EQUAL] 
  // EQUAL takes the top two off the stack, then pushes 1 if
    they're equal, else 0 at the end of the stack

(12)
  stack: [1]
  script [] 

the second part is successful!

There are a couple important properties we gain here.

  • All of the P2SH transactions look the same until they're unlocked.
  • Bitcoin users pay mining fees by the byte, so this moves the cost from the sender to the receiver.

tBTC v2 Deposits

Now, we can unpack the original script:

<eth-address> DROP
<blinding-factor> DROP
DUP HASH160 <signingGroupPubkeyHash> EQUAL
IF
  CHECKSIG
ELSE
  DUP HASH160 <refundPubkeyHash> EQUALVERIFY
  <locktime> CHECKLOCKTIMEVERIFY DROP
  CHECKSIG
ENDIF

Note: the first two commands: <eth-address> DROP <blinding-factor> DROP, do not change the logic, so this ends up being a way to insert arbitrary data into the script. Including the <eth-address> means that whoever locked up money with this script intended for TBTC to be minted to that specific eth address, which we can lookup and verify ethereum-side.

Let's test the script with an invalid unlocking script: [<unrelatedSignature> <unrelatedPublicKey>]

(1)
  stack: []
  script: [<unrelatedSignature> <unrelatedPublicKey>
    <eth-address> DROP <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(2)
  stack: [<unrelatedSignature>]
  script: [<unrelatedPublicKey> <eth-address> DROP
    <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(3)
  stack: [<unrelatedSignature> <unrelatedPublicKey>]
  script: [<eth-address> DROP <blinding-factor> DROP DUP
    HASH160 <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE
    DUP HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(4)
  stack: [<unrelatedSignature> <unrelatedPublicKey>
    <eth-address>]
  script: [DROP <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // DROP removes the last thing on the stack

(5)
  stack: [<unrelatedSignature> <unrelatedPublicKey>]
  script: [<blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(6)
  stack: [<unrelatedSignature> <unrelatedPublicKey>
    <blinding-factor>]
  script: [DROP DUP HASH160 <signingGroupPubkeyHash> EQUAL
    IF CHECKSIG ELSE DUP HASH160 <refundPubkeyHash>
    EQUALVERIFY <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG
    ENDIF]
  // DROP removes the last thing on the stack

(7)
  stack: [<unrelatedSignature> <unrelatedPublicKey>]
  script: [DUP HASH160 <signingGroupPubkeyHash> EQUAL IF
    CHECKSIG ELSE DUP HASH160 <refundPubkeyHash> EQUALVERIFY
    <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // DUP adds a copy of the end of the stack

(8)
  stack: [<unrelatedSignature> <unrelatedPublicKey>
    <unrelatedPublicKey>]
  script: [HASH160 <signingGroupPubkeyHash> EQUAL IF
    CHECKSIG ELSE DUP HASH160 <refundPubkeyHash> EQUALVERIFY
    <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // HASH160 takes the last item from the stack, hashes it,
    and pushes the result

(9)
  stack: [<unrelatedSignature> <unrelatedPublicKey>
    <unrelatedPublicKeyHash>]
  script: [<signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE
    DUP HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(10)
  stack: [<unrelatedSignature> <unrelatedPublicKey>
    <unrelatedPublicKeyHash> <signingGroupPubkeyHash>]
  script: [EQUAL IF CHECKSIG ELSE DUP HASH160
    <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // EQUAL takes the top two off the stack, then pushes 1 if
    they're equal, else 0 at the end of the stack

(11)
  stack: [<unrelatedSignature> <unrelatedPublicKey> 0]
  script: [IF CHECKSIG ELSE DUP HASH160 <refundPubkeyHash>
    EQUALVERIFY <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG
    ENDIF]
  // IF removes the top item from the stack. If it's a 1, we
    use the IF part of the script. Otherwise, we use the
    ELSE part.

(12)
  stack: [<unrelatedSignature> <unrelatedPublicKey>]
  script: [DUP HASH160 <refundPubkeyHash> EQUALVERIFY
    <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG]
  // DUP adds a copy of the end of the stack

(13)
  stack: [<unrelatedSignature> <unrelatedPublicKey>
    <unrelatedPublicKey>]
  script: [HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG]
  // HASH160 takes the last item from the stack, hashes it,
    and pushes the result

(14)
  stack: [<unrelatedSignature> <unrelatedPublicKey>
    <unrelatedPublicKeyHash>]
  script: [<refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG]

(15)
  stack: [<unrelatedSignature> <unrelatedPublicKey>
    <unrelatedPublicKeyHash> <refundPubkeyHash>]
  script: [EQUALVERIFY <locktime> CHECKLOCKTIMEVERIFY DROP
    CHECKSIG]
  // EQUALVERIFY removes the top two items of the stack and
    throws an error if they aren't equal

(16)
  error!

The provided public key didn't match either <signingGroupPubkeyHash> or <refundPubkeyHash>.

What about when we provide <signingGroupPubkey>?

(1)
  stack: []
  script: [<signingGroupSignature> <signingGroupPubkey>
    <eth-address> DROP <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(2)
  stack: [<signingGroupSignature>]
  script: [<signingGroupPubkey> <eth-address> DROP
    <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(3)
  stack: [<signingGroupSignature> <signingGroupPubkey>]
  script: [<eth-address> DROP <blinding-factor> DROP DUP
    HASH160 <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE
    DUP HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(4)
  stack: [<signingGroupSignature> <signingGroupPubkey>
    <eth-address>]
  script: [DROP <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // DROP removes the last thing on the stack

(5)
  stack: [<signingGroupSignature> <signingGroupPubkey>]
  script: [<blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(6)
  stack: [<signingGroupSignature> <signingGroupPubkey>
    <blinding-factor>]
  script: [DROP DUP HASH160 <signingGroupPubkeyHash> EQUAL
    IF CHECKSIG ELSE DUP HASH160 <refundPubkeyHash>
    EQUALVERIFY <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG
    ENDIF]
  // DROP removes the last thing on the stack

(7)
  stack: [<signingGroupSignature> <signingGroupPubkey>]
  script: [DUP HASH160 <signingGroupPubkeyHash> EQUAL IF
    CHECKSIG ELSE DUP HASH160 <refundPubkeyHash> EQUALVERIFY
    <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // DUP adds a copy of the end of the stack

(8)
  stack: [<signingGroupSignature> <signingGroupPubkey>
    <signingGroupPubkey>]
  script: [HASH160 <signingGroupPubkeyHash> EQUAL IF
    CHECKSIG ELSE DUP HASH160 <refundPubkeyHash> EQUALVERIFY
    <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // HASH160 takes the last item from the stack, hashes it,
    and pushes the result

(9)
  stack: [<signingGroupSignature> <signingGroupPubkey>
    <signingGroupPubkeyHash>]
  script: [<signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE
    DUP HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(10)
  stack: [<signingGroupSignature> <signingGroupPubkey>
    <signingGroupPubkeyHash> <signingGroupPubkeyHash>]
  script: [EQUAL IF CHECKSIG ELSE DUP HASH160
    <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // EQUAL takes the top two off the stack, then pushes 1 if
    they're equal, else 0 at the end of the stack

(11)
  stack: [<signingGroupSignature> <signingGroupPubkey> 1]
  script: [IF CHECKSIG ELSE DUP HASH160 <refundPubkeyHash>
    EQUALVERIFY <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG
    ENDIF]
  // IF removes the top item from the stack. If it's a 1, we
    use the IF part of the script. Otherwise, we use the ELSE
    part.

(12)
  stack: [<unrelatedSignature> <unrelatedPublicKey>]
  script: [CHECKSIG]
  // CHECKSIG removes the top two items of the stack and
    pushes 1 if the first item is a signature that matches the
    second.

(13)
  stack: [1]
  script: []
  success!

Last two cases: we unlock using [<refundSignature> <refundPublickey>] before and after <locktime>. Starting with before.

(1)
  stack: []
  script: [<refundSignature> <refundPublicKey> <eth-address>
    DROP <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(2)
  stack: [<refundSignature>]
  script: [<refundPublicKey> <eth-address> DROP
    <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(3)
  stack: [<refundSignature> <refundPublicKey>]
  script: [<eth-address> DROP <blinding-factor> DROP DUP
    HASH160 <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE
    DUP HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(4)
  stack: [<refundSignature> <refundPublicKey> <eth-address>]
  script: [DROP <blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // DROP removes the last thing on the stack

(5)
  stack: [<refundSignature> <refundPublicKey>]
  script: [<blinding-factor> DROP DUP HASH160
    <signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE DUP
    HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(6)
  stack: [<refundSignature> <refundPublicKey>
    <blinding-factor>]
  script: [DROP DUP HASH160 <signingGroupPubkeyHash> EQUAL
    IF CHECKSIG ELSE DUP HASH160 <refundPubkeyHash>
    EQUALVERIFY <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG
    ENDIF]
  // DROP removes the last thing on the stack

(7)
  stack: [<refundSignature> <refundPublicKey>]
  script: [DUP HASH160 <signingGroupPubkeyHash> EQUAL IF
    CHECKSIG ELSE DUP HASH160 <refundPubkeyHash> EQUALVERIFY
    <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // DUP adds a copy of the end of the stack

(8)
  stack: [<refundSignature> <refundPublicKey> <refundPublicKey>]
  script: [HASH160 <signingGroupPubkeyHash> EQUAL IF
    CHECKSIG ELSE DUP HASH160 <refundPubkeyHash> EQUALVERIFY
    <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // HASH160 takes the last item from the stack, hashes it,
    and pushes the result

(9)
  stack: [<refundSignature> <refundPublicKey>
    <refundPubkeyHash>]
  script: [<signingGroupPubkeyHash> EQUAL IF CHECKSIG ELSE
    DUP HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]

(10)
  stack: [<refundSignature> <refundPublicKey>
    <refundPubkeyHash> <signingGroupPubkeyHash>]
  script: [EQUAL IF CHECKSIG ELSE DUP HASH160
    <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG ENDIF]
  // EQUAL takes the top two off the stack, then pushes 1 if
    they're equal, else 0 at the end of the stack

(11)
  stack: [<refundSignature> <refundPublicKey> 0]
  script: [IF CHECKSIG ELSE DUP HASH160 <refundPubkeyHash>
    EQUALVERIFY <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG
    ENDIF]
  // IF removes the top item from the stack. If it's a 1, we
    use the IF part of the script. Otherwise, we use the ELSE
    part.

(12)
  stack: [<refundSignature> <refundPublicKey>]
  script: [DUP HASH160 <refundPubkeyHash> EQUALVERIFY
    <locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG]
  // DUP adds a copy of the end of the stack

(13)
  stack: [<refundSignature> <refundPublicKey>
    <refundPublicKey>]
  script: [HASH160 <refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG]
  // HASH160 takes the last item from the stack, hashes it,
    and pushes the result

(14)
  stack: [<refundSignature> <refundPublicKey>
    <refundPubkeyHash>]
  script: [<refundPubkeyHash> EQUALVERIFY <locktime>
    CHECKLOCKTIMEVERIFY DROP CHECKSIG]

(15)
  stack: [<refundSignature> <refundPublicKey>
    <refundPubkeyHash> <refundPubkeyHash>]
  script: [EQUALVERIFY <locktime> CHECKLOCKTIMEVERIFY DROP
    CHECKSIG]
  // EQUALVERIFY removes the top two items of the stack and
    throws an error if they aren't equal

(16)
  stack: [<refundSignature> <refundPublicKey>]
  script: [<locktime> CHECKLOCKTIMEVERIFY DROP CHECKSIG]

(17)
  stack: [<refundSignature> <refundPublicKey> <locktime>]
  script: [CHECKLOCKTIMEVERIFY DROP CHECKSIG]
  // CHECKLOCKTIMEVERIFY checks to see if the current time
    is after the last item on the stack. If it isn't, it
    throws an error.

(18)
  stack: [<refundSignature> <refundPublicKey> <locktime>]
  script: [DROP CHECKSIG]
  error! current time is before <locktime>

Then, repeating this from Step 17 when the current time is after <locktime>:

(17)
  stack: [<refundSignature> <refundPublicKey> <locktime>]
  script: [CHECKLOCKTIMEVERIFY DROP CHECKSIG]
  // CHECKLOCKTIMEVERIFY checks to see if the current time
    is after the last item on the stack. If it isn't, it
    throws an error.

(18)
  stack: [<refundSignature> <refundPublicKey> <locktime>]
  script: [DROP CHECKSIG]
  // DROP removes the last thing on the stack

(19)
  stack: [<refundSignature> <refundPublicKey>]
  script: [CHECKSIG]
  // CHECKSIG removes the top two items of the stack and
    pushes 1 if the first item is a signature that matches
    the second.

(20)
  stack: [1]
  script: []

This means we end up with the following logic:

  • The signing group address can always unlock the UTXO
  • The refund address can only unlock the UTXO after <locktime>
  • No other address can unlock the UTXO

So that's the underlying script. Then we hash this to get the P2SH address, and the final locking script looks like [HASH160 <38d06abe62e70fb3a72dd4d08554e767683079c4> EQUAL]

The transformation into P2SH does two important things:

  • It looks like any other P2SH transaction, providing wide wallet support.
  • Changing any of the variables makes the hash completely different. Specifically, this means that changing <eth-address>, <blinding-factor> or <refundPubkeyHash> makes you end up with a totally different hash.

That means that each tBTC depositor gets their own one-time-use deposit address, and no one watching bitcoin is able to tell that this is a tBTC transaction. Later, the depositor reveals the above information to Ethereum, and the client is able to recreate the underlying script, hash it, find the transaction on bitcoin, associate the deposit with a minting request, and pull the destination Ethereum address from the deposit.

Wrapping Up

Once the system verifies that a transaction happened [note 3], we're able to "optimistically mint" your tBTC.

A set of nodes called "Minters" watch for transactions that follow this structure. If one of them thinks it's a valid transaction, they propose to mint tBTC. Another set of nodes called "Guardians" watch for Minters making these requests. The Guardians verify that the deposit looks good, and if none of them thinks it's invalid within 3 hours, the mint goes through.

In the meantime, the signing group has until <locktime> to unlock the deposit, and re-lock it with a P2PKH using its own public key hash. This means the UTXO is no longer locked with a script that allows a refund, and we don't have to worry about folks minting tBTC and then later double-spending the underlying BTC.

Want to see all of this in action? You can give tBTC v2 a try today!


[note 1]: Stack-based data types only have two operations: "push" which adds an element to the collection, and "pop", which removes the most-recently-added element (and makes it available for processing).

[note 2]: I'm simplifying by running this calculator, but the actual hashing process is a little more complicated.

[note 3]: We perform a SPV proof using our Relay, and wait for 6 bitcoin block confirmations.