A lock-only network involves users locking their Bitcoin using the self-custodial Bitcoin Staking script without a Babylon chain operating. In this document, we precisely define how one can construct the Bitcoin transactions specified by the Bitcoin Staking protocol.
- Scripts doc - document which defines how different Bitcoin Staking scripts look like
- BIP341- a document specifying how to spend taproot outputs
The lock-only staking system is governed by a set of parameters that specify
what constitutes a valid staking transaction. Based on those,
an observer of the Bitcoin ledger can precisely identify which transactions
are valid staking transactions and whether they should be considered active stake.
These parameters are different depending on the Bitcoin height a transaction is
included in and a constructor of a Bitcoin Staking transaction should take them into
account before propagating a transaction to Bitcoin.
For the rest of the document, we will refer to those parameters as global_parameters
.
More details about parameters can be found in the parameters spec.
Taproot outputs are outputs whose locking script is an elliptic curve point Q
created as follows:
Q = P + hash(P||m)G
where:
P
is the internal public keym
is the root of a Merkle tree whose leaves consist of a version number and a script
For Bitcoin Staking transactions, the internal public key is chosen as:
P = lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0)
This key is described in the BIP341 specification.
The use of this key as an internal public key disables spending from taproot output through the key spending path. The construction of this key can be found here.
A staker enters the system through the creation of a staking transaction which locks Bitcoin in the Bitcoin Staking script.
For the transaction to be considered a valid staking transaction, it must:
- have a taproot output which has the key spending path disabled
and commits to a script tree composed of three scripts::
timelock script, unbonding script, slashing script.
This output is henceforth known as the
staking_output
and the value in this output is known asstaking_amount
- have
OP_RETURN
output which contains:global_parameters.tag
,version
,staker_pk
,finality_provider_pk
,staking_time
- all the values must be valid for the
global_parameters
which are applicable at the height in which the staking transaction is included in the BTC ledger.
Data in the OP_RETURN output is described by the following struct:
type V0OpReturnData struct {
Tag []byte
Version byte
StakerPublicKey []byte
FinalityProviderPublicKey []byte
StakingTime []byte
}
The implementation of the struct can be found here
Fields description:
Tag
- 4 bytes, tag which is used to identify the staking transaction among other transactions in the Bitcoin ledger. It is specified in theglobal_parameters.Tag
field.Version
- 1 byte, current version of the OP_RETURN outputStakerPublicKey
- 32 bytes, staker public key. The same key must be used in the scripts used to create the taproot output in the staking transaction.FinalityProviderPublicKey
- 32 bytes, finality provider public key. The same key must be used in the scripts used to create the taproot output in the staking transaction.StakingTime
- 2 bytes big-endian unsigned number, staking time. The same timelock time must be used in scripts used to create the taproot output in the staking transaction.
This data is serialized as follows:
SerializedStakingData = Tag || Version || StakerPublicKey || FinalityProviderPublicKey || StakingTime
To transform this data into OP_RETURN data:
StakingDataPkScript = 0x6a || 0x47 || SerializedStakingData
where:
- 0x6a - is byte marker representing OP_RETURN op code
- 0x47 - is byte marker representing OP_DATA_71 op code, which pushed 71 bytes onto the stack
The final OP_RETURN output will have the following shape:
TxOut {
Value: 0,
PkScript: StakingDataPkScript
}
Logic creating output from data can be found here
Staking output should commit to three scripts:
timelock_script
unbonding_script
slashing_script
Data needed to create staking_output
:
staker_public_key
- chosen by the user sending the staking transaction. It will be used in every script. This key needs to be put in the OP_RETURN output in the staking transaction.finality_provider_public_key
- chosen by the user sending the staking transaction. It will be used as<FinalityPk>
inslashing_script
. In the lock-only network there is no slashing, so this key has mostly informative purposes. This key needs to be put in the OP_RETURN output of the staking transaction.staking_time
- chosen by the user sending the staking transaction. It will be used as locking time in thetimelock_script
. It must be a validuint16
number, in the rangeglobal_parameters.min_staking_time <= staking_time <= global_parameters.max_staking_time
. It needs to be put in the OP_RETURN output of the staking transaction.covenant_committee_public_keys
- it can be retrieved fromglobal_parameters.covenant_pks
. It is set of covenant committee public keys which will be put inunbonding_script
andslashing_script
.covenant_committee_quorum
- it can be retrieved fromglobal_parameters.covenant_quorum
. It is quorum of covenant committee member required to authorize spending usingunbonding_script
orslashing_script
staking_amout
- chosen by the user, it will be placed instaking_output.value
btc_network
- btc network on which staking transactions will take place
The Babylon staking library exposes the BuildV0IdentifiableStakingOutputsAndTx function with the following signature:
func BuildV0IdentifiableStakingOutputsAndTx(
tag []byte,
stakerKey *btcec.PublicKey,
fpKey *btcec.PublicKey,
covenantKeys []*btcec.PublicKey,
covenantQuorum uint32,
stakingTime uint16,
stakingAmount btcutil.Amount,
net *chaincfg.Params,
) (*IdentifiableStakingInfo, *wire.MsgTx, error)
It enables the caller to create valid outputs to put inside an unfunded and not-signed staking transaction.
The suggested way of creating and sending a staking transaction using bitcoind is:
- create
staker_key
in the bitcoind wallet - create unfunded and not signed staking transaction using
the
BuildV0IdentifiableStakingOutputsAndTx
function - serialize the unfunded and not signed staking transaction to
staking_transaction_hex
- call
bitcoin-cli fundrawtransaction "staking_transaction_hex"
to retrievefunded_staking_transaction_hex
. The bitcoind wallet will automatically choose unspent outputs to fund this transaction. - call
bitcoin-cli signrawtransactionwithwallet "funded_staking_transaction_hex"
. This call will sign all inputs of the transaction and returnsigned_staking_transaction_hex
. - call
bitcoin-cli sendrawtransaction "signed_staking_transaction_hex"
The unbonding transaction allows the staker to on-demand unbond their locked Bitcoin stake prior to its original timelock expiration.
For the transaction to be considered a valid unbonding transaction, it must:
- have exactly one input and one output
- input must be valid a staking output
- output must be a taproot output. This taproot output must have disabled
the key spending path, and committed to script tree composed of two scripts:
the timelock script and the slashing script. This output is henceforth known
as the
unbonding_output
- timelock in the time lock script must be equal to
global_parameters.unbonding_time
- value in the unbonding output must be equal to
staking_output.value - global_parameters.unbonding_fee
The Babylon Bitcoin staking library exposes the BuildUnbondingInfo function which builds a valid unbonding output. It has the following signature:
func BuildUnbondingInfo(
stakerKey *btcec.PublicKey,
fpKeys []*btcec.PublicKey,
covenantKeys []*btcec.PublicKey,
covenantQuorum uint32,
unbondingTime uint16,
unbondingAmount btcutil.Amount,
net *chaincfg.Params,
) (*UnbondingInfo, error)
where:
stakerKey
- must be the same key as the staker key instaking_transaction
fpKeys
- must contain one key, which is the same finality provider key used instaking_transaction
covenantKeys
- are the same covenant keys as used instaking_transaction
covenantQuorum
- is the same quorum as used instaking_transaction
unbondingTime
- is equal toglobal_parameters.unbonding_time
unbondingAmount
- is equal tostaking_amount - global_parameters.unbonding_fee
To create transactions which spend from taproot outputs, either staking output or unbonding output, providing signatures satisfying the script is not enough.
The spender must also provide:
- the whole script which is being spent
- the control block which contains: leaf version, internal public key, and proof of inclusion of the given script in the script tree
Given that creating scripts is deterministic for given data, it is possible to avoid storing scripts by re-building scripts when the need arises.
To build the script and control block necessary to spend from a staking output through the timelock script, the following function could be implemented
import (
// Babylon btc staking library
"github.com/babylonchain/babylon/btcstaking"
)
func buildTimelockScriptAndControlBlock(
stakerKey *btcec.PublicKey,
finalityProviderKey *btcec.PublicKey,
covenantKeys []*btcec.PublicKey,
covenantQuorum uint32,
stakingTime uint16,
stakingAmount btcutil.Amount,
netParams *chaincfg.Params,
) ([]byte, []byte, error) {
stakingInfo, err := btcstaking.BuildStakingInfo(
stakerKey,
[]*btcec.PublicKey{finalityProviderKey},
covenantKeys,
covenantQuorum,
stakingTime,
stakingAmount,
netParams,
)
if err != nil {
return nil, nil, err
}
si, err := stakingInfo.TimeLockPathSpendInfo()
if err != nil {
return nil, nil, err
}
scriptBytes := si.RevealedLeaf.Script
controlBlock := si.ControlBlock
controlBlockBytes, err := controlBlock.ToBytes()
if err != nil {
return nil, nil, err
}
return scriptBytes, controlBlockBytes, nil
}
The returned script and control block can be used to either build the witness directly or to put them in a PSBT which can be used by bitcoind to create the witness.
To avoid creating signatures/witness manually, Bitcoind's walletprocesspsbt can be used. To use this Bitcoind endpoint to get signature/witness the wallet must maintain one of the keys used in the script.
Example of creating psbt to sign unbonding transaction using unbonding script from staking output:
import (
"github.com/btcsuite/btcd/btcutil/psbt"
)
func BuildPsbtForSigningUnbondingTransaciton(
unbondingTx *wire.MsgTx,
stakingOutput *wire.TxOut,
stakerKey *btcec.PublicKey,
spentLeaf *txscript.TapLeaf,
controlBlockBytes []byte,
) (string, error) {
psbtPacket, err := psbt.New(
[]*wire.OutPoint{&unbondingTx.TxIn[0].PreviousOutPoint},
unbondingTx.TxOut,
unbondingTx.Version,
unbondingTx.LockTime,
[]uint32{unbondingTx.TxIn[0].Sequence},
)
if err != nil {
return "", fmt.Errorf("failed to create PSBT packet with unbonding transaction: %w", err)
}
psbtPacket.Inputs[0].SighashType = txscript.SigHashDefault
psbtPacket.Inputs[0].WitnessUtxo = stakingOutput
psbtPacket.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{
{
PubKey: stakerKey.SerializeCompressed(),
},
}
psbtPacket.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{
{
ControlBlock: controlBlockBytes,
Script: spentLeaf.Script,
LeafVersion: spentLeaf.LeafVersion,
},
}
return psbtPacket.B64Encode()
}
Given that to spend through the unbonding script requires more than the
staker's signature, the walletprocesspsbt
endpoint will produce a new psbt
with the staker signature attached.
In the case of a timelock path which requires only the staker's signature,
walletprocesspsbt
would produce the whole witness required to send the
transaction to the BTC network.