How to Build Bitcoin-Backed Smart Contract Insurance on Ethereum 2025
Prerequisites & Environment Checklist
Before writing a single line of Solidity, confirm your toolchain matches the versions below. Version mismatches—especially between ethers.js v5 and v6—cause subtle silent failures that are painful to debug mid-build.
| Tool | Version | Install Command | Purpose |
|---|---|---|---|
| Node.js | 20+ | nvm install 20 | JS runtime for Hardhat |
| Hardhat | 2.22+ | npm i -D hardhat | EVM dev framework |
| ethers.js | v6 | npm i ethers@6 | Contract interaction |
| @chainlink/contracts | 1.2+ | npm i @chainlink/contracts | AggregatorV3Interface |
| OpenZeppelin Contracts | v5 | npm i @openzeppelin/contracts | Ownable, ReentrancyGuard |
| @nomicfoundation/hardhat-ignition | 0.15+ | npm i -D @nomicfoundation/hardhat-ignition-ethers | Deployment modules |
- [ ] Node.js 20+ installed and active via nvm
- [ ] Hardhat project initialized with TypeScript (
npx hardhat init) - [ ] Sepolia RPC URL available (Alchemy or Infura free tier works)
- [ ] Sepolia ETH in wallet from sepoliafaucet.com
- [ ] Etherscan API key from etherscan.io/apis
- [ ] MetaMask or a funded private key stored in
.env
mkdir btc-insurance && cd btc-insurance
npx hardhat init # choose TypeScript project
npm i @openzeppelin/contracts @chainlink/contracts
npm i -D @nomicfoundation/hardhat-ignition-ethers @nomicfoundation/hardhat-ethers dotenv
Estimated time: 45–60 minutes for a developer comfortable with Solidity and Hardhat.
Step 1: Design the Insurance Policy Data Model
The data model is the foundation everything else references. Getting the struct right before writing business logic prevents costly storage layout migrations later. The key insight here is denominating collateral requirements in BTC units (WBTC has 8 decimals) rather than ETH, so the vault's solvency is pegged to Bitcoin's value, not Ethereum's.
Defining policy structs: coverage amount, premium, BTC collateral ratio
Each policy tracks the coverage amount in USD (scaled to 18 decimals for Solidity arithmetic), how much WBTC collateral was deposited (8 decimals), the premium paid by the insured, policy lifecycle state, and an expiry timestamp.
Mapping policy states: PENDING, ACTIVE, CLAIMED, EXPIRED
A state machine prevents double-claims and allows clean expiry logic. PENDING exists briefly during creation before collateral is confirmed. ACTIVE means collateral is locked and coverage is live.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
enum PolicyState { PENDING, ACTIVE, CLAIMED, EXPIRED }
struct InsurancePolicy {
uint256 policyId;
address holder; // insured party
uint256 coverageUSD; // coverage in USD, scaled 1e18
uint256 btcCollateralRequired; // WBTC units (8 decimals)
uint256 btcCollateralDeposited;// actual WBTC deposited (8 decimals)
uint256 premiumPaid; // WBTC premium deposited by holder
PolicyState state;
uint256 createdAt;
uint256 expiresAt; // unix timestamp
}
Calculating BTC-denominated premium using live price feeds
The premium is expressed as a percentage of the BTC collateral (e.g., 2%). This means the insured pays in WBTC proportional to how much BTC-value they're having insured, making the premium economically rational at any BTC price. We'll compute collateral amounts dynamically in Step 2 using the live Chainlink feed.
Note: Never store USD amounts as raw integers without a decimal context comment. Always document the scale factor (1e18 for USD, 1e8 for WBTC) in the struct definition or you will introduce bugs when integrating with external tokens.
Step 2: Integrate Chainlink BTC/USD Price Feed for Collateral Valuation
Chainlink's AggregatorV3Interface is the canonical way to get BTC/USD on Ethereum. You're fetching a price that a decentralized oracle network has already validated across multiple data providers—using this instead of a single centralized price source is what makes the collateral calculation trustworthy and manipulation-resistant.
Fetching real-time BTC/USD price with AggregatorV3Interface
The latestRoundData() function returns the price as an int256 scaled to 8 decimals. So a BTC price of $60,000 comes back as 6000000000000 (60000 × 1e8).
Handling price staleness and round validation safely
Always check updatedAt against a staleness threshold. On mainnet forks or Sepolia, feeds can lag. If you skip this check, your contract will happily use a 24-hour-old price to calculate collateral, which could be off by thousands of dollars per BTC.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
contract PriceFeedConsumer {
AggregatorV3Interface public immutable priceFeed;
uint256 public constant STALENESS_THRESHOLD = 3600; // 1 hour
constructor(address _feed) {
priceFeed = AggregatorV3Interface(_feed);
}
/// @notice Returns required WBTC collateral (8 decimals) for a given USD coverage
/// @param coverageUSD Coverage amount in USD scaled to 1e18
/// @param collateralRatioBps Collateral ratio in basis points (e.g. 15000 = 150%)
function getRequiredCollateral(
uint256 coverageUSD,
uint256 collateralRatioBps
) public view returns (uint256 wbtcAmount) {
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
require(price > 0, "Invalid BTC price");
require(answeredInRound >= roundId, "Stale price: round incomplete");
require(
block.timestamp - updatedAt <= STALENESS_THRESHOLD,
"Chainlink price is stale"
);
// price is 8 decimals, coverageUSD is 18 decimals
// wbtcAmount (8 decimals) = (coverageUSD * ratio) / (btcPriceUSD * 1e18)
// Simplify: coverageUSD(1e18) / price(1e8) = result in 1e10, scale to 1e8
uint256 btcPriceUSD = uint256(price); // 8 decimals
wbtcAmount = (coverageUSD * collateralRatioBps * 1e8) /
(btcPriceUSD * 10000 * 1e10);
}
}
Note: The decimal arithmetic here is the most error-prone part of the entire project. Write unit tests for
getRequiredCollateralfirst, before wiring it into the vault. Verify: at $60,000 BTC and 150% ratio, $10,000 of coverage should require approximately 0.0025 WBTC.
Step 3: Write the Core Insurance Smart Contract
Now we wire the policy model and price feed into a single vault contract. This is where OpenZeppelin's ReentrancyGuard and Ownable earn their keep: ReentrancyGuard prevents the classic reentrancy attack on payout, and Ownable gates the claim approval function so only an authorized adjuster (or a future DAO) can trigger payouts.
The full contract below follows the Checks-Effects-Interactions (CEI) pattern rigorously: all state changes happen before any external token transfers.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
contract InsuranceVault is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
AggregatorV3Interface public immutable priceFeed;
IERC20 public immutable wbtc;
uint256 public constant STALENESS_THRESHOLD = 3600;
uint256 public constant PREMIUM_BPS = 200; // 2%
uint256 public constant COVERAGE_DURATION = 30 days;
uint256 public constant COLLATERAL_RATIO_BPS = 15000; // 150%
uint256 private _nextPolicyId;
mapping(uint256 => InsurancePolicy) public policies;
event PolicyCreated(uint256 indexed policyId, address indexed holder, uint256 coverageUSD);
event PolicyClaimed(uint256 indexed policyId, address indexed holder, uint256 payout);
event PolicyExpired(uint256 indexed policyId);
constructor(address _priceFeed, address _wbtc) Ownable(msg.sender) {
priceFeed = AggregatorV3Interface(_priceFeed);
wbtc = IERC20(_wbtc);
}
function createPolicy(uint256 coverageUSD) external nonReentrant returns (uint256 policyId) {
require(coverageUSD > 0, "Coverage must be > 0");
uint256 collateralRequired = _getRequiredCollateral(coverageUSD);
uint256 premium = (collateralRequired * PREMIUM_BPS) / 10000;
uint256 totalRequired = collateralRequired + premium;
policyId = _nextPolicyId++;
// EFFECTS before INTERACTIONS
policies[policyId] = InsurancePolicy({
policyId: policyId,
holder: msg.sender,
coverageUSD: coverageUSD,
btcCollateralRequired: collateralRequired,
btcCollateralDeposited: collateralRequired,
premiumPaid: premium,
state: PolicyState.ACTIVE,
createdAt: block.timestamp,
expiresAt: block.timestamp + COVERAGE_DURATION
});
emit PolicyCreated(policyId, msg.sender, coverageUSD);
// INTERACTIONS last
wbtc.safeTransferFrom(msg.sender, address(this), totalRequired);
}
/// @notice Owner-approved claim payout. Insured receives collateral as compensation.
function claimPolicy(uint256 policyId) external nonReentrant onlyOwner {
InsurancePolicy storage policy = policies[policyId];
// CHECKS
require(policy.state == PolicyState.ACTIVE, "Policy not active");
require(block.timestamp <= policy.expiresAt, "Policy expired");
uint256 currentCollateralValue = _getRequiredCollateral(policy.coverageUSD);
require(
policy.btcCollateralDeposited >= currentCollateralValue,
"Policy undercollateralized"
);
// EFFECTS
uint256 payout = policy.btcCollateralDeposited;
policy.state = PolicyState.CLAIMED;
policy.btcCollateralDeposited = 0;
emit PolicyClaimed(policyId, policy.holder, payout);
// INTERACTIONS
wbtc.safeTransfer(policy.holder, payout);
}
/// @notice Returns collateral to protocol after policy expiry. Premium retained.
function expirePolicy(uint256 policyId) external nonReentrant {
InsurancePolicy storage policy = policies[policyId];
require(policy.state == PolicyState.ACTIVE, "Policy not active");
require(block.timestamp > policy.expiresAt, "Policy still active");
uint256 refund = policy.btcCollateralDeposited;
policy.state = PolicyState.EXPIRED;
policy.btcCollateralDeposited = 0;
emit PolicyExpired(policyId);
// Return collateral to vault owner (protocol treasury)
wbtc.safeTransfer(owner(), refund);
}
function _getRequiredCollateral(uint256 coverageUSD) internal view returns (uint256) {
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(price > 0, "Invalid BTC price");
require(block.timestamp - updatedAt <= STALENESS_THRESHOLD, "Chainlink price is stale");
uint256 btcPriceUSD = uint256(price);
return (coverageUSD * COLLATERAL_RATIO_BPS * 1e8) / (btcPriceUSD * 10000 * 1e10);
}
}
Note: In the
claimPolicyfunction, the collateral check compares current BTC price to collateral on deposit. If BTC has risen since policy creation, the same WBTC is now worth more USD—the policy becomes over-collateralized, which is fine. The check protects against the reverse: if BTC crashed so hard the deposited WBTC no longer covers the USD coverage amount.
Step 4: Write and Run Hardhat Tests for Policy Lifecycle
Tests are non-negotiable for financial contracts. The goal here is to exercise every state transition and prove the revert paths work correctly. We use a MockV3Aggregator (provided by @chainlink/contracts) to simulate BTC price movement without needing a live feed.
// test/InsuranceVault.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { InsuranceVault, MockV3Aggregator, ERC20Mock } from "../typechain-types";
describe("InsuranceVault", function () {
let vault: InsuranceVault;
let mockFeed: MockV3Aggregator;
let mockWbtc: ERC20Mock;
let owner: any, holder: any;
const BTC_PRICE_60K = 6_000_000_000_000n; // $60,000 with 8 decimals
const BTC_PRICE_20K = 2_000_000_000_000n; // $20,000 with 8 decimals
const COVERAGE_USD = ethers.parseUnits("10000", 18); // $10,000 coverage
beforeEach(async function () {
[owner, holder] = await ethers.getSigners();
// Deploy MockV3Aggregator: 8 decimals, initial price $60,000
const MockFeed = await ethers.getContractFactory("MockV3Aggregator");
mockFeed = await MockFeed.deploy(8, BTC_PRICE_60K);
// Deploy a simple ERC20 mock for WBTC (8 decimals)
const MockToken = await ethers.getContractFactory("ERC20Mock");
mockWbtc = await MockToken.deploy("Wrapped Bitcoin", "WBTC", 8);
// Mint WBTC to holder: 0.1 WBTC (8 decimals = 10_000_000)
await mockWbtc.mint(holder.address, 10_000_000n);
const Vault = await ethers.getContractFactory("InsuranceVault");
vault = await Vault.deploy(
await mockFeed.getAddress(),
await mockWbtc.getAddress()
);
// Approve vault to spend holder's WBTC
await mockWbtc.connect(holder).approve(await vault.getAddress(), 10_000_000n);
});
it("happy path: creates policy and owner can claim payout", async function () {
const tx = await vault.connect(holder).createPolicy(COVERAGE_USD);
const receipt = await tx.wait();
const policyId = 0n;
const policy = await vault.policies(policyId);
expect(policy.state).to.equal(1); // ACTIVE
expect(policy.holder).to.equal(holder.address);
// Owner approves claim
const holderBalanceBefore = await mockWbtc.balanceOf(holder.address);
await vault.connect(owner).claimPolicy(policyId);
const holderBalanceAfter = await mockWbtc.balanceOf(holder.address);
expect(holderBalanceAfter).to.be.gt(holderBalanceBefore);
const claimedPolicy = await vault.policies(policyId);
expect(claimedPolicy.state).to.equal(2); // CLAIMED
});
it("edge case: BTC price drops to $20K causing undercollateralization, claimPolicy reverts", async function () {
// Create policy at $60K BTC — collateral is sized for $60K price
await vault.connect(holder).createPolicy(COVERAGE_USD);
// BTC crashes to $20K — now the same WBTC amount covers less USD
await mockFeed.updateAnswer(BTC_PRICE_20K);
// At $20K BTC, required collateral for $10K USD coverage is 3x more WBTC
// The deposited amount at $60K is insufficient — should revert
await expect(
vault.connect(owner).claimPolicy(0n)
).to.be.revertedWith("Policy undercollateralized");
});
it("edge case: cannot claim expired policy", async function () {
await vault.connect(holder).createPolicy(COVERAGE_USD);
// Fast-forward time past coverage duration (30 days)
await ethers.provider.send("evm_increaseTime", [31 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await expect(
vault.connect(owner).claimPolicy(0n)
).to.be.revertedWith("Policy expired");
});
it("expirePolicy returns collateral to owner after TTL", async function () {
await vault.connect(holder).createPolicy(COVERAGE_USD);
await ethers.provider.send("evm_increaseTime", [31 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
const ownerBefore = await mockWbtc.balanceOf(owner.address);
await vault.connect(owner).expirePolicy(0n);
const ownerAfter = await mockWbtc.balanceOf(owner.address);
expect(ownerAfter).to.be.gt(ownerBefore);
});
});
Run the suite with npx hardhat test. All four tests should pass in under 5 seconds on the local Hardhat network.
Note: The undercollateralization test intentionally checks the current price against the originally deposited collateral. This is the core economic vulnerability the check guards against. In a production system, you'd also want a liquidation bot that calls
expirePolicyor triggers top-up requests before the collateral ratio breaches the minimum.
Step 5: Deploy to Sepolia Testnet with Hardhat Ignition
Hardhat Ignition is the recommended deployment framework as of Hardhat 2.22+. It gives you idempotent deployments, automatic artifact tracking, and clean TypeScript ergonomics. Configure your hardhat.config.ts first, then write the Ignition module.
Configuring hardhat.config.ts with Sepolia RPC and Etherscan API key
// hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-ignition-ethers";
import "@nomicfoundation/hardhat-verify";
import * as dotenv from "dotenv";
dotenv.config();
const config: HardhatUserConfig = {
solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 } } },
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY || "",
},
};
export default config;
Writing an Ignition deployment module for the insurance contract
The Sepolia Chainlink BTC/USD feed address is 0x1b44F3514812d835EB1BDB0acB33d3fA3351Ee43. For WBTC, deploy a mock ERC20 on Sepolia since real WBTC liquidity is limited on testnet.
// ignition/modules/InsuranceVault.ts
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
const SEPOLIA_BTC_USD_FEED = "0x1b44F3514812d835EB1BDB0acB33d3fA3351Ee43";
export default buildModule("InsuranceVaultModule", (m) => {
// Deploy a mock WBTC for Sepolia testing
const mockWbtc = m.contract("ERC20Mock", [
"Wrapped Bitcoin",
"WBTC",
8,
]);
// Deploy the InsuranceVault using the live Chainlink feed
const vault = m.contract("InsuranceVault", [
SEPOLIA_BTC_USD_FEED,
mockWbtc,
]);
return { mockWbtc, vault };
});
Deploying and verifying on Etherscan
# Deploy to Sepolia
npx hardhat ignition deploy ignition/modules/InsuranceVault.ts --network sepolia
# Verify on Etherscan (replace with your deployed address)
npx hardhat verify --network sepolia <VAULT_ADDRESS> \
"0x1b44F3514812d835EB1BDB0acB33d3fA3351Ee43" \
"<MOCK_WBTC_ADDRESS>"
After verification, navigate to your contract on sepolia.etherscan.io and use the Read Contract tab to call policies(0) and confirm the Chainlink feed is returning a live BTC price.
Common Issues & Fixes
Error: 'Chainlink price is stale' revert on local fork
Cause: When forking mainnet or Sepolia with hardhat_reset, the block.timestamp stays at the fork block but updatedAt from the oracle reflects the real last update—which can be hours old, exceeding your STALENESS_THRESHOLD.
Fix: Either lower the threshold in tests, or advance time after forking:
// In your test beforeEach, after forking:
await ethers.provider.send("evm_setNextBlockTimestamp", [
Math.floor(Date.now() / 1000)
]);
await ethers.provider.send("evm_mine", []);
Alternatively, use MockV3Aggregator in unit tests (as shown in Step 4) and reserve forked-network tests for integration scenarios where you explicitly manage timestamps.
Error: WBTC decimal mismatch causing collateral underflow (8 vs 18 decimals)
Cause: WBTC uses 8 decimals, not 18. If you treat 1 WBTC as 1e18 instead of 1e8, your collateral calculations will be off by a factor of 1e10, causing either massive over-collateralization or near-zero collateral requirements.
Before (broken):
// Wrong: treating WBTC like an 18-decimal token
uint256 wbtcAmount = (coverageUSD * 1e18) / btcPriceUSD;
After (correct):
// Correct: WBTC is 8 decimals; btcPriceUSD is also 8 decimals
// coverageUSD is 1e18, so we need to cancel 1e18 / (1e8 * 1e10) = 1e8 result
uint256 wbtcAmount = (coverageUSD * COLLATERAL_RATIO_BPS * 1e8) /
(btcPriceUSD * 10000 * 1e10);
Verify your math with a concrete test: $10,000 USD coverage at $60,000 BTC/USD at 150% ratio = 0.0025 WBTC = 250_000 in 8-decimal units.
Error: ReentrancyGuard blocking legitimate multi-step claim flows
Cause: If you build a claim flow where your contract calls an external adjuster contract, which then calls back into claimPolicy, the nonReentrant modifier correctly reverts the re-entry. But sometimes developers structure legitimate multi-step flows that inadvertently look like reentrancy.
Fix: Restructure so the insurance contract is always the last call in the chain (pull-over-push). Instead of pushing WBTC to the holder inside claimPolicy, use a withdrawal pattern:
mapping(address => uint256) public pendingWithdrawals;
// In claimPolicy: mark for withdrawal instead of direct transfer
pendingWithdrawals[policy.holder] += payout;
// Separate function holder calls independently
function withdraw() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
wbtc.safeTransfer(msg.sender, amount);
}
This completely eliminates reentrancy concerns on the withdrawal path and makes gas estimation more predictable.
FAQ
Q: Can I use native BTC instead of WBTC as collateral on Ethereum?
No—native Bitcoin cannot be held by Ethereum smart contracts directly because BTC lives on a separate blockchain with no EVM compatibility. Your options are WBTC (an ERC-20 backed 1:1 by BTC held in custody by BitGo), tBTC (a decentralized Threshold Network bridge), or waiting for Chainlink CCIP-enabled cross-chain collateral flows. For production systems where custodial risk matters, tBTC offers a more trust-minimized bridge. WBTC is fine for prototyping but introduces centralized custodian risk.
Q: How do I add a human arbitration layer for disputed claims?
Replace the onlyOwner modifier on claimPolicy with a multi-sig or DAO vote mechanism. The simplest production-grade approach is a Gnosis Safe multi-sig as the contract owner: require M-of-N keyholders to submit the claim transaction. For full on-chain arbitration, integrate with Kleros Court (an ERC-792-compatible arbitration standard)—their protocol lets you escalate disputes to a decentralized jury. The InsuranceVault contract would emit a DisputeRaised event, your backend submits it to Kleros, and the ruling callback calls claimPolicy or rejects it. See the Kleros documentation for the IArbitrable interface.
Q: Is this pattern production-ready or does it need an audit first?
This tutorial demonstrates the correct patterns (CEI, ReentrancyGuard, SafeERC20, price staleness checks) but the contract is not production-ready without a professional audit. At minimum, run it through Slither and Mythril for automated static analysis, then review OpenZeppelin's Smart Contract Security Guidelines before considering a mainnet deploy. A contract holding BTC collateral is a high-value target—budget for at least one reputable audit firm (Trail of Bits, OpenZeppelin, or Spearbit) before going live. Estimated audit cost for a contract of this complexity: $15,000–$40,000 USD.