BLK 06Build Β· πŸ”΅ technical Β· β‰ˆ4 h Β· guided lab

Lab I β€” TyphoonCover,
a parametric product end to end

You are the product engineer for a Philippines-market windstorm cover sold through a super-app. Design the trigger, encode the terms, handle the failure modes, deploy, and then study the famous product that did all this in 2017 β€” and still died.

LO5 Β· design a parametric product end-to-endLO2 Β· deploy & test a contract

L1Product design before code

Peril
Typhoon wind damage, one fixed coverage zone (e.g. a province) per contract deployment.
Index & trigger
Peak 10-minute sustained wind β‰₯ 185 km/h reported inside the cover window by the authorised oracle (production: aggregate PAGASA + international feeds via a Chainlink-style network; lab: one oracle account).
Payout
Fixed 0.2 ETH per cover (stand-in for β‚±; stablecoins in production β€” see M09/M10). No adjuster, no claim form.
Premium
Fixed 0.02 ETH, sold only before the cover window opens (no buying cover once the typhoon is on the radar!).
Capacity
The contract refuses sales it couldn't pay: reserve β‰₯ sold Γ— payout, checked at purchase. Solvency-as-code.
Failure modes
Oracle silence β†’ premium refunds after a grace period. Settlement window β†’ insurer sweeps residual reserve only 30 days after cover ends.
Basis-risk disclosure
Marketing must say it plainly: payment follows the measured index, not your roof. (M05 L2.)

L2The contract

TyphoonCover.sol Β· Solidity ^0.8.24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// TyphoonCover β€” parametric windstorm cover (teaching build).
/// Trigger: authorised oracle reports peak wind for the fixed zone.
/// If kph >= TRIGGER during the window, every holder can claim the fixed payout.
contract TyphoonCover {
    address public immutable insurer;
    address public oracle;                      // authorised reporter

    uint256 public constant PREMIUM     = 0.02 ether;
    uint256 public constant PAYOUT      = 0.2 ether;
    uint32  public constant TRIGGER_KPH = 185;
    uint64  public immutable coverStart;
    uint64  public immutable coverEnd;

    struct Cover { bool active; bool settled; }
    mapping(address => Cover) public covers;
    uint256 public soldCount;
    uint32  public reportedPeakKph;
    bool    public triggered;

    event CoverBought(address indexed holder);
    event WindReported(uint32 kph, bool triggered);
    event PayoutClaimed(address indexed holder, uint256 amount);
    event PremiumRefunded(address indexed holder);

    error WrongPremium();  error SaleClosed();   error AlreadyCovered();
    error NotOracle();     error OutsideWindow(); error NotTriggered();
    error NothingToSettle(); error TooEarly();    error NoDataFailure();

    constructor(address _oracle, uint64 _start, uint64 _end) payable {
        insurer = msg.sender; oracle = _oracle;
        coverStart = _start; coverEnd = _end;      // deployer funds the reserve via VALUE
    }

    /// Sales close when the cover window opens β€” no buying mid-typhoon.
    function buyCover() external payable {
        if (msg.value != PREMIUM) revert WrongPremium();
        if (block.timestamp >= coverStart) revert SaleClosed();
        if (covers[msg.sender].active) revert AlreadyCovered();
        // solvency-as-code: never sell cover the reserve can't pay
        require(address(this).balance >= (soldCount + 1) * PAYOUT, "capacity exhausted");
        covers[msg.sender] = Cover(true, false);
        soldCount++;
        emit CoverBought(msg.sender);
    }

    /// Oracle reports readings during the window; the max is retained.
    function reportWind(uint32 kph) external {
        if (msg.sender != oracle) revert NotOracle();
        if (block.timestamp < coverStart || block.timestamp > coverEnd) revert OutsideWindow();
        if (kph > reportedPeakKph) reportedPeakKph = kph;
        if (kph >= TRIGGER_KPH) triggered = true;
        emit WindReported(kph, triggered);
    }

    /// Pull-payment claims: each holder claims their own payout (no payout loops β€” M02 wound #6).
    function claim() external {
        if (!triggered) revert NotTriggered();
        Cover storage c = covers[msg.sender];
        if (!c.active || c.settled) revert NothingToSettle();
        c.settled = true;                               // effects before interaction
        (bool ok, ) = msg.sender.call{value: PAYOUT}("");
        require(ok, "transfer failed");
        emit PayoutClaimed(msg.sender, PAYOUT);
    }

    /// Escape hatch: window closed and the oracle NEVER reported β†’ refund premiums.
    /// An oracle outage must strand the insurer, not the customer.
    function refundOnDataFailure() external {
        if (block.timestamp <= coverEnd + 3 days) revert TooEarly();
        if (reportedPeakKph != 0 || triggered) revert NoDataFailure();
        Cover storage c = covers[msg.sender];
        if (!c.active || c.settled) revert NothingToSettle();
        c.settled = true;
        (bool ok, ) = msg.sender.call{value: PREMIUM}("");
        require(ok, "transfer failed");
        emit PremiumRefunded(msg.sender);
    }

    /// Residual reserve returns to the insurer only after the settlement window.
    function sweep() external {
        require(msg.sender == insurer, "not insurer");
        require(block.timestamp > coverEnd + 30 days, "settlement open");
        (bool ok, ) = insurer.call{value: address(this).balance}("");
        require(ok, "transfer failed");
    }
}

Every design decision from L1 is visible in code β€” that's the point of the exercise:

L3Run it in Remix

βš—

Lab 6.1 Β· Full typhoon drill

Remix VM Β· ~45 min
  1. Stage the actors

    Remix VM gives you accounts. Cast: #1 insurer (deployer), #2 oracle, #3–4 customers. Copy #2's address now.

  2. Deploy funded

    Compile (0.8.24+). Constructor args: _oracle = account #2's address; _start = current unix time + 300 (5 min from now β€” get it from Date.now()/1000|0 in your browser console); _end = start + 600. Set VALUE = 1 ether (reserve for 5 covers) and Deploy from account #1.

  3. Sell cover

    From #3 and #4: VALUE = 0.02 ether β†’ buyCover(). Try a 5th purchase from #3 again β€” AlreadyCovered(). Read soldCount, covers(#3).

  4. Try to cheat time

    From #3, after start time passes, buy again from a fresh account β†’ SaleClosed(). (In Remix VM, time advances with each mined tx; you can also redeploy with a tighter window.)

  5. Landfall

    Once inside the window, from #2 (oracle): reportWind(140) β†’ event shows triggered=false. Then reportWind(192) β†’ triggered=true. From #4 (not oracle) try reporting β€” NotOracle().

  6. Settle

    From #3: claim() β†’ +0.2 ETH. Claim again β†’ NothingToSettle(). From an address that never bought β†’ NothingToSettle().

  7. Post-mortem sweep

    sweep() from #1 too early β†’ "settlement open". This is your time-lock protecting customers.

  8. Rerun the disaster-that-didn't-happen

    Redeploy; sell; let the window close with no oracle report; wait past +3 days (redeploy with short windows to simulate); customers call refundOnDataFailure(). The outage cost the insurer, not the customer β€” as designed.

Optional β€” real testnet: switch environment to "Injected Provider β€” MetaMask" on Sepolia, faucet some test ETH (M00 links), redeploy with small values (0.0002/0.002 ether) and repeat with two browser profiles. Everything you did locally works identically in public.

L4Edge-case review β€” the adjuster's mindset, in code

ScenarioContract behaviourProduction upgrade
Oracle compromisedSingle point of failure (lab simplification)Decentralised oracle committee, median-of-N, signed sources, on-chain proofs
Oracle silent all windowRefund hatch after grace periodSame, plus secondary data source and incident SLA
Wind 184 km/h, roofs goneNo payout β€” basis risk by designTiered triggers (e.g. 150/185/220 β†’ 25/60/100%) narrow the cliff
Everyone claims at onceFine β€” pull payments, no loopsSame pattern; batch relayers for UX
Insurer wants reserve earlyTime-locked sweep refusesMultisig + published settlement calendar
Premium currency volatilityETH-denominated (lab)Stablecoin premiums/payouts (see Aon 2026; M09–M10)

L5Case file β€” AXA "fizzy" (2017–2019)

fizzy Β· flight-delay parametric on public Ethereum

discontinued 2019

AXA launched fizzy in 2017: buy flight-delay cover; if the airline data feed showed β‰₯ 2 h delay, automatic payout β€” no claim. Technically it worked and was genuinely pioneering (a major insurer, on public Ethereum, in production).

Why it died anyway: distribution (a standalone site nobody visited at booking time β€” cover must live inside the purchase flow, which is why your TyphoonCover ships inside a super-app), demand (customers didn't seek the product), and unit economics (payout ratios and gas made margins thin).

Transferable lesson: the contract is ~10% of a parametric product. Distribution, pricing, and data governance are the other 90 β€” Etherisc and Arbol survived by solving those (M08).

L6Appendix β€” the professional toolchain (Foundry)

When you outgrow Remix: Foundry gives you a local compiler, a test framework in Solidity, and mainnet-fork testing.

shell + test sketch
# install (per foundry docs), then:
forge init typhoon-cover && cd typhoon-cover
# drop TyphoonCover.sol into src/, then a test in test/:

function test_TriggerPaysHolder() public {
    vm.warp(start - 1);  vm.prank(customer);
    cover.buyCover{value: 0.02 ether}();
    vm.warp(start + 10); vm.prank(oracleAddr);
    cover.reportWind(192);
    vm.prank(customer);  cover.claim();
    assertEq(customer.balance, before + 0.2 ether);
}
forge test -vvv

vm.warp (time travel) and vm.prank (impersonation) make the whole Lab 6.1 drill scriptable β€” that's the difference between demos and engineering. The full 32-h fCC course (M02) covers this stack end to end.

Teaching contract β€” not audited, not production. Production parametric adds decentralised oracles, tiered triggers, stablecoin settlement, KYC gates and regulatory product approval. fizzy history per AXA announcements and contemporaneous reporting.