Bifrost service

All Thorchain nodes run a Bifrost service, which is primarily responsible for interacting with external chains. This involves both inbound and outbound transactions from supported external chains. The Bifrost service for each chain client consists of two components:

  1. Observer. This scans blocks in external chains to watch for vault addresses. Any inbound transactions discovered by the observer are converted into THORChain witness transactions to be sent to the THORChain L1 layer.

  2. Signer. The THORChain L1 layer processes the inbound transaction to return a remittance request to the signer. This outbound request is sent to a chain client specific to the external chain, which then signs and broadcasts the transaction.

image.png

THORChain Bifrost uses Asgard vaults, which are used to both receive and send assets from external chains. These Asgard vaults use a threshold signature scheme (TSS) requiring signatures from multiple nodes to sign a transaction. Each Asgard vault is limited to a maximum of 20 nodes, and it scales based on the number of nodes in the network. The observer looks for UTXO spending into these vaults and sends witness transactions to THORChain.

type Tx struct {
	ID          TxID    `json:"id"`
	Chain       Chain   `json:"chain"`
	FromAddress Address `json:"from_address"`
	ToAddress   Address `json:"to_address"`
	Coins       Coins   `json:"coins"`
	Gas         Gas     `json:"gas"`
	Memo        string  `json:"memo"`
}

The Bifrost module also maintains an LRU cache to avoid resigning the same transaction.

UTXO Chain Client

The UTXO chain client allows the Bifrost module to create a new client through NewClient:

func NewClient(
	thorKeys *thorclient.Keys,
	cfg config.BifrostChainConfiguration,
	server *gotss.TssServer,
	bridge thorclient.ThorchainBridge,
	m *metrics.Metrics,
) (*Client, error) {
	// verify the chain is supported
	supported := map[common.Chain]bool{
		common.DOGEChain: true,
		common.BCHChain:  true,
		common.LTCChain:  true,
		common.BTCChain:  true,
	}
	if !supported[cfg.ChainID] {
		return nil, fmt.Errorf("unsupported utxo chain: %s", cfg.ChainID)
	}
	// create rpc client
	rpcClient, err := rpc.NewClient(cfg.RPCHost, cfg.UserName, cfg.Password, cfg.MaxRPCRetries, logger)
	// node key setup
	tssKeysign, err := tss.NewKeySign(server, bridge)

It allows the creation of a chain client for any of the supported UTXO chains. This also sets up the RPC connection with the respective external chain to scan and extract transactions, and it registers the TSS key to sign a transaction for the Asgard vault assigned to the node.

The client parses the raw transaction to check that at least one output is spent towards an Asgard vault and that another output has an OP_RETURN. The resulting witness transaction is then sent to THORChain.

func (c *Client) getTxIn(tx *btcjson.TxRawResult, height int64, isMemPool bool, vinZeroTxs map[string]*btcjson.TxRawResult) (types.TxInItem, error) {
[...]
	if c.isAsgardAddress(toAddr) {
		// only inbound UTXO need to be validated against multi-sig
		if !c.isValidUTXO(output.ScriptPubKey.Hex) {
			return types.TxInItem{}, fmt.Errorf("invalid utxo")
		}
	}
	amount, err := btcutil.NewAmount(output.Value)
	if err != nil {
		return types.TxInItem{}, fmt.Errorf("fail to parse float64: %w", err)
	}
	amt := uint64(amount.ToUnit(btcutil.AmountSatoshi))

	gas, err := c.getGas(tx)
	if err != nil {
		return types.TxInItem{}, fmt.Errorf("fail to get gas from tx: %w", err)
	}
	return types.TxInItem{
		BlockHeight: height,
		Tx:          tx.Txid,
		Sender:      sender,
		To:          toAddr,
		Coins: common.Coins{
			common.NewCoin(c.cfg.ChainID.GetGasAsset(), cosmos.NewUint(amt)),
		},
		Memo: memo,
		Gas:  gas,
	}

Inbound transactions are “conf-counted” by the chain client and sent to THORChain along with the required confirmation count. THORChain does not process these transactions until the required block height is reached (i.e., for a required confirmation count of one, it will immediately process the transaction). The UTXO chain client computes the required confirmations in getBlockRequiredConfirmation:

// getBlockRequiredConfirmation find out how many confirmation the given txIn need to have before it can be send to THORChain
func (c *Client) getBlockRequiredConfirmation(txIn types.TxIn, height int64) (int64, error) {
	totalTxValue := txIn.GetTotalTransactionValue(c.cfg.ChainID.GetGasAsset(), c.asgardAddresses)
	totalFeeAndSubsidy, err := c.getCoinbaseValue(height)
	if err != nil {
		c.log.Err(err).Msgf("fail to get coinbase value")
	}
	confMul, err := utxo.GetConfMulBasisPoint(c.GetChain().String(), c.bridge)
[...]
	confValue := common.GetUncappedShare(confMul, cosmos.NewUint(constants.MaxBasisPts), cosmos.SafeUintFromInt64(totalFeeAndSubsidy))
	confirm := totalTxValue.Quo(confValue).Uint64()
	confirm, err = utxo.MaxConfAdjustment(confirm, c.GetChain().String(), c.bridge)
	if err != nil {
		c.log.Err(err).Msgf("fail to get max conf value adjustment for %s", c.GetChain().String())
	}
	if confirm < c.cfg.MinConfirmations {
		confirm = c.cfg.MinConfirmations
	}
[...]
	return int64(confirm), nil
}

The confirmation count is calculated by dividing the total transaction value of the block (spending on Asgard vaults) to the derived confirmation value based on the Coinbase value and chain-specific multiplier. If this is lower than the configured minimum for the chain, the configured value is used instead.

Signer

Once there is consensus among the majority nodes and the transaction is finalized, it is sent to the chain client for that chain. The signing process is invoked through the SignTx method in the ChainClient interface.

// ChainClient is the interface for chain clients.
type ChainClient interface {
[...]
	// SignTx returns the signed transaction.
	SignTx(tx types.TxOutItem, height int64) ([]byte, []byte, *types.TxInItem, error)

The signer uses the TSS module to process the key signing for the assigned Asgard vault. Once signed, the final transaction is broadcasted to the external chain.

image.png

The signer for the UTXO client specifically performs the following operations:

  • It verifies that the transaction is for the correct chain.

  • It looks up the signer cache to verify that the transaction has not already been signed.

  • It acquires a vault-specific lock to prevent concurrent signing.

  • It decodes and verifies the recipient address (ToAddress), retrieves the source script for inputs, and processes any existing checkpoint to handle partially signed transactions.

  • If no checkpoint exists, it builds the transaction from scratch, serializes it, and prepares inputs for signing.

  • Each input is signed in parallel through the chain-specific signing function for the UTXO chain. This uses the TSS signing module to sign the inputs for the Asgard vaults.

  • Once all inputs are signed, the transaction is serialized back into its UTXO-specific format, and its size and gas usage are calculated.

  • It then constructs the final signed transaction, a checkpoint (for recovery on failure), and observation details (like gas used and outbound amount) to broadcast or handle the transaction further.

If the transaction signing is successful, the Bifrost module broadcasts it to the external chain to be executed.

Zellic © 2025Back to top ↑