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:
- Verify the transaction contains the correct output (amount and recipient)
 - Prove the transaction is included in a Bitcoin block (Merkle proof)
 - Ensure the block is part of the canonical chain
 - (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:
- ✅ Sends more than 1 BTC
 - ✅ Sends to the correct address
 - ✅ Is included in a valid Bitcoin block
 - ✅ 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).