Bitcoin Integration
Verifiying a Transaction

Verifying Bitcoin Transactions on Starknet: BitcoinDepositor Tutorial

Overview

In this tutorial, we'll build a "BitcoinDepositor" contract where a user can become the official depositor by being the first to send 1 BTC or more to a specific Bitcoin address: 1LgXWxpELt2o9hPGiwqDT1B5Z7994MQPTN. We'll learn how to verify these Bitcoin transactions on Starknet using the Utu Relayer Contract (opens in a new tab).

The complete code for this tutorial is available in the UTU example repository (opens in a new tab), which uses Raito (opens in a new tab) for Bitcoin transaction verification.

Contract Implementation

Contract Interface

#[starknet::interface]
pub trait IBitcoinDepositor<TContractState> {
    fn prove_deposit(
        ref self: TContractState,
        deposit_tx: Transaction,
        output_id: usize,
        block_height: u64,
        block_header: BlockHeader,
        tx_inclusion: Array<(Digest, bool)>
    );
    fn get_depositor(self: @TContractState) -> ContractAddress;
}

Storage

#[storage]
struct Storage {
    depositor: ContractAddress,
    utu_address: ContractAddress,
}

Core Verification Logic

When verifying Bitcoin transactions on Starknet, several crucial checks need to be performed:

  1. Verify the transaction contains the correct output (amount and recipient)
  2. Prove the transaction is included in a Bitcoin block (Merkle proof)
  3. Ensure the block is part of the canonical chain
  4. (Recommended) Add security checks for block maturity and cumulative work

The following implementation shows the basic verification, but for production use, you should add additional security measures:

#[external(v0)]
impl BitcoinDepositor of IBitcoinDepositor<ContractState> {
    fn prove_deposit(
        ref self: ContractState,
        deposit_tx: Transaction,
        output_id: usize,
        block_height: u64,
        block_header: BlockHeader,
        tx_inclusion: Array<(Digest, bool)>
    ) {
        // Check no previous depositor
        assert(self.depositor.read() == Zero::zero(), 'too late, someone deposited');
        
        // 1. Verify amount and recipient
        let output_to_check = deposit_tx.outputs[output_id];
        assert(*output_to_check.value > 100_000_000_u64, 'you sent less than 1 BTC');
        let target = extract_p2pkh_target(*output_to_check.pk_script);
        assert(target == "1LgXWxpELt2o9hPGiwqDT1B5Z7994MQPTN", 'wrong receiver');

        // 2. Verify transaction inclusion in block
        let tx_bytes_legacy = @deposit_tx.encode();
        let txid = double_sha256_byte_array(tx_bytes_legacy);
        let merkle_root = compute_merkle_root(txid, tx_inclusion);
        assert(
            block_header.merkle_root_hash.value == merkle_root.value, 
            'invalid inclusion proof'
        );

        // 3. Verify block is in canonical chain and has sufficient proof of work
        let utu = IUtuRelayDispatcher { contract_address: self.utu_address.read() };
        // Requires 100 sextillion (10^36) expected hashes (strong security)
        utu.assert_safe(block_height, block_header.hash(), 100_000_000_000_000_000_000_000, 0);

        // 4. Verify block timestamp is not from the future
        let block_time = u32_byte_reverse(block_header.time).into();
        assert(block_time <= get_block_timestamp(), 'Block comes from the future');

        // Set depositor
        self.depositor.write(get_caller_address());
    }

    fn get_depositor(self: @ContractState) -> ContractAddress {
        self.depositor.read()
    }
}

Providing Bitcoin data

You can test the contract using this real Bitcoin transaction that sent 1.00043947 BTC to our target address:

Transaction Details:

  • TXID: fa89c32152bf324cd1d47d48187f977c7e0f380f6f78132c187ce27923f62fcc
  • Block Height: 868239
  • Block Hash: 00000000000000000001060b021606cca1bd2c9b4eb15b6a31d2d76bf7f485fd
  • Amount: 1.00043947 BTC (100,043,947 satoshis)
  • Recipient: 1LgXWxpELt2o9hPGiwqDT1B5Z7994MQPTN
  • Block Time: 1730373503 (March 30, 2024)

The transaction can be viewed on any Bitcoin block explorer (opens in a new tab)

This transaction meets all our requirements:

  1. ✅ Sends more than 1 BTC
  2. ✅ Sends to the correct address
  3. ✅ Is included in a valid Bitcoin block
  4. ✅ Is part of the canonical chain

Generating the Proof with TypeScript

Let's use the bitcoin-on-starknet.js (opens in a new tab) library to generate and submit the proof for our transaction. Here's how to do it:

import {
  BitcoinProxiedRpcProvider,
  UtuProvider,
  serializedHash,
} from "bitcoin-on-starknet";
import { Account, RpcProvider, TransactionType, Invocations } from "starknet";
 
// Pre-formatted transaction data for Starknet processing
const EXAMPLE_SERIALIZED_TRANSACTION = [
  2n, 0n, 1n, 4n,
  // ... transaction details ...
  744843869111954496999033090920949585736336206836849668294828n,
  25n, 0n, 0n,
] as const;
 
async function main() {
  // Initialize providers
  const bitcoinProvider = new BitcoinProxiedRpcProvider("https://btcrpc.lfg.rs/rpc");
  const utuProvider = new UtuProvider(bitcoinProvider);
  const starknetProvider = new RpcProvider({
    nodeUrl: "https://sepolia.rpc.starknet.id",
  });
 
  // Initialize Starknet account
  const account = new Account(
    starknetProvider,
    process.env.STARKNET_ADDRESS as string,
    process.env.STARKNET_PRIVATE_KEY as string
  );
 
  try {
    // Fetch transaction and generate proof
    const txId = "fa89c32152bf324cd1d47d48187f977c7e0f380f6f78132c187ce27923f62fcc";
    const rawTransaction = await bitcoinProvider.getRawTransaction(txId, true);
    const header = await bitcoinProvider.getBlockHeader(rawTransaction.blockhash);
    
    // Generate sync transactions and Merkle proof
    const syncTransactions = await utuProvider.getSyncTxs(
      starknetProvider,
      header.height,
      0n
    );
    const txInclusionProof = await utuProvider.getTxInclusionProof(txId);
 
    // Prepare calldata for prove_deposit
    let calldata = EXAMPLE_SERIALIZED_TRANSACTION.map(n => "0x" + n.toString(16));
    calldata.push("0x0"); // output_id
    calldata.push("0x" + header.height.toString(16));
    calldata.push(...utuProvider.serializeBlockHeader(header));
    
    // Add Merkle proof
    calldata.push(txInclusionProof.length);
    txInclusionProof.forEach(([hash, direction]: [string, boolean]) => {
      calldata.push(...serializedHash(hash));
      calldata.push(direction ? "0x1" : "0x0");
    });
 
    // Send a multicall syncing the chain before interacting with our contract
    await account.execute([...syncTransactions, proveDepositCall]);
  } catch (error) {
    console.error("Error:", error);
  }
}
 
main();

You can find the complete code example, including transaction simulation, in the bitcoin-on-starknet.js repository (opens in a new tab).