January 15, 2025

Chainlink Automation: Building a Deadman Switch for Emergency Fund Recovery

12 min readSmart Contracts • Automation • DeFi
Chainlink Automation Deadman Switch

Chainlink Automation enables smart contracts to automatically execute functions based on predefined conditions without requiring external intervention. In this post, we'll go over how I applied it to build a deadman's switch and all of the mistakes I made that you should avoid.

What is a Deadman's Switch?

This morbid-sounding contraption is a digital 'last will and testament' relying on Chainlink Automation to trigger when it gets carried out. With digital funds and assets becoming increasingly popular, a way to recover them and ensure they wind up in the right hands is pertinent.

The point of a "Switch" is to store whatever assets or important information you have, and keep it safe until you become unresponsive. It's a way to ensure that your digital assets are not lost or forgotten. You decide what to put in your vault(s) and designate beneficiaries and their respective percentages to send them to in the event of your death.

You set a time period to 'check in' to your vault(s) and show that you are still alive. If you don't check in within the set time period, the switch is triggered and the assets are sent to the beneficiaries. This can include actual digital funds stored in the vault, NFTs, sensitive information, instructions on finding your private keys and addresses, etc.

Throughout this duration, Chainlink Automation will do routine checks or "upkeeps" to ensure that the switch is still active and that you are still alive. If you do not check in, the switch is triggered and the assets are sent to the beneficiaries.

So What is Chainlink Automation?

Chainlink Automation can be used in a variety of ways.

Key Features

  • • Decentralized execution
  • • Gas-efficient automation
  • • Conditional logic support
  • • Time-based triggers

Other Use Cases

  • • DeFi yield harvesting
  • • Liquidation protection
  • • Emergency fund recovery
  • • Automated rebasing

The Smart Contract Implementation

Here's the actual contract I built and deployed - a multi-vault deadman switch hub that allows users to create multiple vaults with different beneficiaries and timing configurations. This is a more sophisticated approach than a single-vault system.

SwitchVaultHub.sol - The Complete Implementation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/**
 * Single-contract demo of Chainlink Automation with MANY vaults.
 * - Users create vaults (structs) in this hub; no new contracts deployed.
 * - Fund per-vault via deposit(vaultId) payable.
 * - One upkeep scans a bounded window and pays expired vaults in batches.
 * - Failed sends are recorded as "owed[vaultId][recipient]" for later claim.
 */
interface IAutomation {
  function checkUpkeep(bytes calldata) external view returns (bool, bytes memory);
  function performUpkeep(bytes calldata) external;
}

contract SwitchVaultHub is IAutomation {
  // --------------------------- Types & Storage ---------------------------
  struct Beneficiary { address payable to; uint16 bps; } // 10000 = 100%
  struct Vault {
    address owner;
    uint32  heartbeat;    // seconds
    uint32  grace;        // seconds
    uint64  lastHeartbeat;
    bool    executed;
    uint256 balance;      // ETH earmarked for this vault
    Beneficiary[] bens;
  }

  Vault[] public vaults;
  mapping(uint256 => mapping(address => uint256)) public owed; // vaultId => recipient => amount

  // Upkeep paging: small, predictable gas
  uint256 public cursor;                       // round-robin start
  uint256 public constant SCAN_LIMIT  = 25;    // how many vaults to scan per check
  uint256 public constant BATCH_LIMIT = 10;    // how many payouts to execute per perform

  // --------------------------- Events ---------------------------
  event VaultCreated(uint256 indexed id, address indexed owner);
  event Deposited(uint256 indexed id, address indexed from, uint256 amount);
  event CheckedIn(uint256 indexed id, uint256 nextDeadline);
  event Payout(uint256 indexed id, address indexed to, uint256 amount, bool ok);
  event Executed(uint256 indexed id, uint256 when);
  event OwedClaimed(uint256 indexed id, address indexed to, uint256 amount);

  // --------------------------- Modifiers ---------------------------
  modifier onlyOwner(uint256 id) {
    require(id < vaults.length, "bad id");
    require(msg.sender == vaults[id].owner, "not owner");
    _;
  }

  // --------------------------- User API ---------------------------

  /// Create a new vault; send ETH with the tx to pre-fund it (optional).
  function createVault(
    address payable[] calldata recipients,
    uint16[] calldata bps,
    uint32 heartbeatSec,
    uint32 graceSec
  ) external payable returns (uint256 id) {
    require(heartbeatSec > 0, "heartbeat=0");
    require(recipients.length == bps.length && bps.length > 0, "bad arrays");

    // build vault
    id = vaults.length;
    vaults.push();
    Vault storage v = vaults[id];
    v.owner = msg.sender;
    v.heartbeat = heartbeatSec;
    v.grace = graceSec;
    v.lastHeartbeat = uint64(block.timestamp);

    uint256 sum;
    for (uint256 i = 0; i < recipients.length; i++) {
      require(recipients[i] != address(0) && bps[i] > 0, "bad ben");
      v.bens.push(Beneficiary(recipients[i], bps[i]));
      sum += bps[i];
    }
    require(sum == 10000, "sum!=10000");

    if (msg.value > 0) {
      v.balance += msg.value;
      emit Deposited(id, msg.sender, msg.value);
    }
    emit VaultCreated(id, msg.sender);
  }

  /// Add ETH to a specific vault.
  function deposit(uint256 id) external payable {
    require(id < vaults.length, "bad id");
    require(msg.value > 0, "no value");
    vaults[id].balance += msg.value;
    emit Deposited(id, msg.sender, msg.value);
  }

  /// Owner keeps the vault alive.
  function checkIn(uint256 id) external onlyOwner(id) {
    Vault storage v = vaults[id];
    require(!v.executed, "executed");
    v.lastHeartbeat = uint64(block.timestamp);
    emit CheckedIn(id, nextDeadline(id));
  }

  /// Claim a failed payout recorded for you.
  function claimOwed(uint256 id) external {
    require(id < vaults.length, "bad id");
    uint256 amt = owed[id][msg.sender];
    require(amt > 0, "nothing owed");
    owed[id][msg.sender] = 0;
    (bool ok, ) = payable(msg.sender).call{value: amt}("");
    require(ok, "claim failed");
    emit OwedClaimed(id, msg.sender, amt);
  }

  // --------------------------- Views ---------------------------

  function vaultsCount() external view returns (uint256) { return vaults.length; }

  function nextDeadline(uint256 id) public view returns (uint256) {
    Vault storage v = vaults[id];
    return uint256(v.lastHeartbeat) + uint256(v.heartbeat) + uint256(v.grace);
  }

  function expired(uint256 id) public view returns (bool) {
    Vault storage v = vaults[id];
    return !v.executed && block.timestamp >= nextDeadline(id);
  }

  // --------------------------- Internal payout ---------------------------

  function _performPayout(uint256 id) internal {
    Vault storage v = vaults[id];
    require(!v.executed, "executed");
    require(block.timestamp >= nextDeadline(id), "not expired");

    v.executed = true;
    uint256 bal = v.balance;
    v.balance = 0;

    if (bal == 0) { emit Executed(id, block.timestamp); return; }

    for (uint256 i = 0; i < v.bens.length; i++) {
      uint256 share = (bal * v.bens[i].bps) / 10000;
      if (share == 0) continue;
      (bool ok, ) = v.bens[i].to.call{value: share}("");
      if (!ok) { owed[id][v.bens[i].to] += share; }
      emit Payout(id, v.bens[i].to, share, ok);
    }
    emit Executed(id, block.timestamp);
  }

  // --------------------------- Chainlink Automation ---------------------------

  /// Off-chain simulation: collect up to BATCH_LIMIT expired+funded vault IDs starting from cursor.
  function checkUpkeep(bytes calldata)
    external
    view
    override
    returns (bool upkeepNeeded, bytes memory performData)
  {
    uint256 n = vaults.length;
    if (n == 0) return (false, "");

    uint256 found;
    uint256[BATCH_LIMIT] memory ids; // fixed-size scratch array

    for (uint256 i = 0; i < SCAN_LIMIT && found < BATCH_LIMIT; i++) {
      uint256 idx = (cursor + i) % n;
      Vault storage v = vaults[idx];
      if (!v.executed && v.balance > 0 && block.timestamp >= (uint256(v.lastHeartbeat) + v.heartbeat + v.grace)) {
        ids[found++] = idx;
      }
    }
    if (found == 0) return (false, "");

    // pack the hits tightly
    uint256[] memory due = new uint256[](found);
    for (uint256 j = 0; j < found; j++) due[j] = ids[j];
    return (true, abi.encode(due));
  }

  /// On-chain execution: revalidate and pay each due vault, then advance cursor.
  function performUpkeep(bytes calldata performData) external override {
    if (performData.length == 0) return;
    uint256[] memory due = abi.decode(performData, (uint256[]));
    uint256 n = vaults.length;
    uint256 steps = due.length > BATCH_LIMIT ? BATCH_LIMIT : due.length;

    for (uint256 i = 0; i < steps; i++) {
      uint256 id = due[i];
      if (id >= n) continue; // defensive
      Vault storage v = vaults[id];
      if (!v.executed && v.balance > 0 && block.timestamp >= (uint256(v.lastHeartbeat) + v.heartbeat + v.grace)) {
        _performPayout(id); // internal (no external reentrancy into hub logic)
      }
    }
    // advance cursor so we don't always scan from 0
    cursor = n == 0 ? 0 : (cursor + steps) % n;
  }
}

Breaking Down the Contract - Line by Line

1. License and Version Declaration

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
  • // SPDX-License-Identifier: MIT - Standard license identifier for open source contracts
  • pragma solidity ^0.8.24; - Specifies Solidity version 0.8.24 or higher (^ means compatible versions)

2. Documentation Comment

/**
 * Single-contract demo of Chainlink Automation with MANY vaults.
 * - Users create vaults (structs) in this hub; no new contracts deployed.
 * - Fund per-vault via deposit(vaultId) payable.
 * - One upkeep scans a bounded window and pays expired vaults in batches.
 * - Failed sends are recorded as "owed[vaultId][recipient]" for later claim.
 */

This comment explains the contract's purpose: it's a single contract that manages multiple vaults (not separate contracts), uses batching for gas efficiency, and handles failed transfers gracefully.

3. Automation Interface

interface IAutomation {
  function checkUpkeep(bytes calldata) external view returns (bool, bytes memory);
  function performUpkeep(bytes calldata) external;
}

This interface defines the two required functions for Chainlink Automation. Instead of importing the full Chainlink contract, we define our own interface to reduce dependencies and gas costs.

It took me a few attempts of incompatibility issues and "insufficient funds"to realize this. 😬

4. Data Structures

struct Beneficiary { address payable to; uint16 bps; } // 10000 = 100%
struct Vault {
  address owner;
  uint32  heartbeat;    // seconds
  uint32  grace;        // seconds
  uint64  lastHeartbeat;
  bool    executed;
  uint256 balance;      // ETH earmarked for this vault
  Beneficiary[] bens;
}
  • Beneficiary - Stores recipient address and their percentage (bps = basis points, 10000 = 100%)
  • Vault - Contains all vault data: owner, timing, execution status, balance, and beneficiaries
  • uint32 grace Amount of time after the heartbeat to allow for late check-ins
  • uint32 heartbeat Amount of time between check-ins and uint32 to saves gas compared to uint256
  • uint64 lastHeartbeat Last time user checks in and uint64for timestamps is sufficient and more gas-efficient
  • bool executed Indicates if the vault has been executed by returning true or false
  • uint256 balance Amount of ETH in the vault
  • Beneficiary[] bens Array of beneficiaries

A STRUCT is a blueprint for creating objects that contain multiple pieces of information. Each struct can contain different data types (addresses, numbers, booleans, etc.) and even arrays of other structs.

BPS or basis points are used instead of percentages or decimals because Solidity only recognizes whole numbers. So we use 10000 to represent 100%, 100 to represent 1%, and so on.

5. Storage Variables

Vault[] public vaults;
mapping(uint256 => mapping(address => uint256)) public owed; // vaultId => recipient => amount

// Upkeep paging: small, predictable gas
uint256 public cursor;                       // srart scanning at last stopped position
uint256 public constant SCAN_LIMIT  = 25;    // how many vaults to scan per check
uint256 public constant BATCH_LIMIT = 10;    // how many payouts to execute per perform
  • vaults[] - Dynamic array storing all vaults
  • Mapping - used to map through addresses and their corresponding values
  • owed - Nested mapping tracking failed transfers (that are still owed)for later claiming
  • cursor - Tracks where to start scanning, beginning from last stopped position
  • SCAN_LIMIT - Maximum vaults to check per upkeep call (gas limit protection)
  • BATCH_LIMIT - Maximum payouts to execute per upkeep call

6. Events

event VaultCreated(uint256 indexed id, address indexed owner);
event Deposited(uint256 indexed id, address indexed from, uint256 amount);
event CheckedIn(uint256 indexed id, uint256 nextDeadline);
event Payout(uint256 indexed id, address indexed to, uint256 amount, bool ok);
event Executed(uint256 indexed id, uint256 when);
event OwedClaimed(uint256 indexed id, address indexed to, uint256 amount);
  • VaultCreated - Emitted when a vault is created along with the vault ID and owner
  • Deposited - Emitted when a deposit is made
  • CheckedIn - Emitted when a check-in is made along with the vault ID and next deadline
  • Payout - Emitted when a payout is made along with the vault ID, recipient, amount, and if the transfer succeeded
  • Executed - Emitted when a vault is executed along with the vault ID and timestamp
  • OwedClaimed - Emitted when an owed claim is made

Events are indexed for easy filtering and provide transparency for all major contract actions. The bool ok indicates if the transfer succeeded (ok or !ok).

7. Modifier

modifier onlyOwner(uint256 id) {
  require(id < vaults.length, "bad id");
  require(msg.sender == vaults[id].owner, "not owner");
  _;
}

Then onlyOwner modifier ensures only the vault owner can perform certain actions. It checks both that the vault ID is valid and that the caller is the owner.

8. Create Vault Function

function createVault(
  address payable[] calldata recipients,
  uint16[] calldata bps,
  uint32 heartbeatSec,
  uint32 graceSec
) external payable returns (uint256 id) {
  require(heartbeatSec > 0, "heartbeat=0");
  require(recipients.length == bps.length && bps.length > 0, "bad arrays");
  • calldata - More gas-efficient than memory for arrays passed as parameters
  • recipients - Array of addresses of the beneficiaries
  • bps - Array of basis points of the beneficiaries
  • heartbeatSec - Heartbeat in seconds
  • graceSec - Grace period in seconds
  • payable - Allows the function to receive ETH
  • require - Validates heartbeat is positive and arrays match in length

Note:After hours (maybe days?) of trying to figure out why my funds were not being transferred after execution, I realized that the payable keyword was missing. 🤦‍♀️

9. Vault Creation Logic

// build vault
id = vaults.length;
vaults.push();
Vault storage v = vaults[id];
v.owner = msg.sender;
v.heartbeat = heartbeatSec;
v.grace = graceSec;
v.lastHeartbeat = uint64(block.timestamp);
  • id = vaults.length - New vault gets the next available ID
  • vaults.push() - Adds a new empty vault to the array
  • Vault storage v - Creates a storage reference for gas efficiency
  • lastHeartbeat - Set to current timestamp (vault starts "alive")

10. Beneficiary Validation

uint256 sum;
for (uint256 i = 0; i < recipients.length; i++) {
  require(recipients[i] != address(0) && bps[i] > 0, "bad ben");
  v.bens.push(Beneficiary(recipients[i], bps[i]));
  sum += bps[i];
}
require(sum == 10000, "sum!=10000");

This loop validates each beneficiary (non-zero address, positive percentage) and ensures all percentages add up to exactly 10000 (100%). This prevents rounding errors and ensures complete fund distribution.

11. Check In Function

function checkIn(uint256 id) external onlyOwner(id) {
  Vault storage v = vaults[id];
  require(!v.executed, "executed");
  v.lastHeartbeat = uint64(block.timestamp);
  emit CheckedIn(id, nextDeadline(id));
}

The critical "heartbeat" function that resets the timer. Only the vault owner can call it, and it updates the last heartbeat timestamp, effectively extending the deadline.

12. Chainlink Automation - checkUpkeep

function checkUpkeep(bytes calldata)
  external
  view
  override
  returns (bool upkeepNeeded, bytes memory performData)
{
  uint256 n = vaults.length;
  if (n == 0) return (false, "");

  uint256 found;
  uint256[BATCH_LIMIT] memory ids; // fixed-size scratch array

Chainlink Automation is the core of the switch. It is the function that monitors the vaults and checks if they are expired and need to be executed. This function runs off-chain to determine if upkeep is needed. It uses a fixed-size array to avoid dynamic allocation, scanning up to SCAN_LIMIT vaults to find expired ones.

  • checkUpkeep is the function that monitors the vaults and checks if they are expired and need to be executed.
  • external is used to indicate that the function is external.
  • override is used to override the function in the IAutomation interface.
  • bytes calldata is the data passed to the function.
  • view is used to indicate that the function does not modify the state of the contract.
  • returns is used to return the data from the function.
  • upkeepNeeded is a boolean that indicates if upkeep is needed.
  • performData is the data that is passed to the function.

checkUpkeep and performUpkeep are the two functions that are used to monitor the vaults and execute the expired vaults.

Chainlink will look for these 2 functions to decide if your contract is automation compatible.

13. Expiration Check Logic

// Constants defined earlier in contract
uint256 public constant SCAN_LIMIT  = 25;    // how many vaults to scan per check
uint256 public constant BATCH_LIMIT = 10;    // how many payouts to execute per perform

for (uint256 i = 0; i < SCAN_LIMIT && found < BATCH_LIMIT; i++) {
  uint256 idx = (cursor + i) % n;
  Vault storage v = vaults[idx];
  if (!v.executed && v.balance > 0 && block.timestamp >= (uint256(v.lastHeartbeat) + v.heartbeat + v.grace)) {
    ids[found++] = idx;
  }
}
  • for (uint256 i = 0; i < SCAN_LIMIT - loop through vaults starting from cursor up to scan limit
  • && found < BATCH_LIMIT; i++) - stop when we find batch limit expired vaults
  • i++ - increment the loop
  • found - Number of expired vaults found
  • BATCH_LIMIT - Maximum number of vaults to execute per upkeep (defined as constant = 10)
  • SCAN_LIMIT - Maximum number of vaults to scan per upkeep (defined as constant = 25)
  • (cursor + i) % n
    • cursor is the index we left off on in the last upkeep and where we'll begin scanning from,
    • i is how far we are in the current loop (0, 1, 2, 3...), up to the SCAN_LIMIT
    • % is the index we are currently on, and n is the number of vaults
    • If we go past the end (>= n), % n "wraps us back" to the beginning.
  • Checks three conditions: not executed, has balance, and expired
  • Expiration formula: lastHeartbeat + heartbeat + grace
  • Stops when we find BATCH_LIMIT expired vaults or scan SCAN_LIMIT total

14. Payout Execution

function _performPayout(uint256 id) internal {
  Vault storage v = vaults[id];
  require(!v.executed, "executed");
  require(block.timestamp >= nextDeadline(id), "not expired");

  v.executed = true;
  uint256 bal = v.balance;
  v.balance = 0;

  if (bal == 0) { emit Executed(id, block.timestamp); return; }

  for (uint256 i = 0; i < v.bens.length; i++) {
    uint256 share = (bal * v.bens[i].bps) / 10000;
    if (share == 0) continue;
    (bool ok, ) = v.bens[i].to.call{value: share}("");
    if (!ok) { owed[id][v.bens[i].to] += share; }
    emit Payout(id, v.bens[i].to, share, ok);
  }
  emit Executed(id, block.timestamp);
}
  • Vault storage v = vaults[id]; - storage v is the vault we are working with
  • require(!v.executed, "executed"); - checks if the vault has already been executed
  • require(block.timestamp >= nextDeadline(id), "not expired"); - checks if the vault has expired
  • v.executed = true; - marks the vault as executed
  • uint256 bal = v.balance; - gets the balance of the vault
  • v.balance = 0; - sets the balance of the vault to 0
  • if (bal == 0) { emit Executed(id, block.timestamp); return; } - if the balance is 0, emits the Executed event and returns
  • Loops through each beneficiary and calculates their share using basis points
  • Uses .call{value: share}("") for gas-efficient ETH transfers:
    • .call - A low-level function in Solidity for sending Ether (or making arbitrary calls)
    • Unlike .transfer() or .send(), it doesn't enforce a fixed gas stipend — it forwards all remaining gas by default
    • {value: share} - This curly-brace syntax means: send share amount of Ether (in wei) along with the call
    • ("") - The parentheses hold the data payload (the function selector + arguments you want to call). Here it's just an empty string "", meaning: no function is called, just send Ether to the recipient's fallback/receive function
    • (bool ok, ) - .call returns two values: A boolean (ok) → whether the call succeeded, and Bytes data (ignored here with _/,). If ok == false, the Ether wasn't received successfully
  • Records failed transfers in owed mapping for later claiming
  • Emits events for transparency and monitoring

Key Design Decisions & Lessons Learned

✅ What Worked Well

  • • Single contract approach reduces deployment costs
  • • Round-robin scanning prevents gas limit issues
  • • Failed transfer handling via owed mapping
  • • Basis points (10000) prevent rounding errors
  • • Fixed-size arrays in checkUpkeep save gas

❌ Mistakes I Made (Learn from These!)

  • • Initially tried to scan all vaults at once (hit gas limits)
  • • Didn't handle failed transfers initially (lost funds!)
  • • Used uint256 for timestamps (wasted gas)
  • • Didn't implement proper access controls initially
  • • Forgot to emit events for failed transfers

Deployment and Setup

1. Deploy the Contract

// No constructor parameters needed - much simpler!
SwitchVaultHub hub = new SwitchVaultHub();

2. Create Your First Vault

// Example: Create vault with 2 beneficiaries
address payable[] memory recipients = new address payable[](2);
recipients[0] = payable(0x...); // 60% to first beneficiary
recipients[1] = payable(0x...); // 40% to second beneficiary

uint16[] memory bps = new uint16[](2);
bps[0] = 6000; // 60%
bps[1] = 4000; // 40%

uint256 vaultId = hub.createVault{value: 1 ether}(
  recipients,
  bps,
  30 days,    // heartbeat every 30 days
  7 days      // 7 day grace period
);

3. Register with Chainlink Automation

Visit the Chainlink Automation dashboard and create a new upkeep:

  • Target Contract: Your deployed SwitchVaultHub address
  • Gas Limit: 800,000 (higher due to batch processing)
  • Starting Balance: 5-10 LINK tokens
  • Check Interval: 1 day (more frequent checking)

Usage Flow

1

Deploy and Register

Deploy the hub contract and register it with Chainlink Automation.

2

Create Vaults

Call createVault() with your beneficiaries, percentages, and timing parameters.

3

Deposit Funds

Send ETH to specific vaults using deposit(vaultId).

4

Regular Check-ins

Call checkIn(vaultId) before each vault's deadline expires.

!

Emergency Scenario

If you don't check in, Chainlink Automation will automatically distribute funds to beneficiaries.

The Complete Solution: SwitchManager + SwitchVaultHub

Here's the reality: I actually needed to build BOTH contracts for this to work properly. The SwitchVaultHub handles the vault data and logic, but the SwitchManager is what Chainlink Automation actually monitors. This separation allows for better gas efficiency and modularity. Here's the manager contract that works alongside the hub:

SwitchManager.sol - The Automation Manager

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IMySwitch {
  function expired() external view returns (bool);
  function executed() external view returns (bool);
  function performPayout() external;
}

interface AutomationCompatibleInterface {
  function checkUpkeep(bytes calldata) external view returns (bool, bytes memory);
  function performUpkeep(bytes calldata) external;
}

contract SwitchManager is AutomationCompatibleInterface {
  address[] public vaults;
  uint256 public constant SCAN_LIMIT = 25;
  uint256 public constant BATCH_LIMIT = 10;
  uint256 public cursor;

  event VaultAdded(address vault);
  event VaultExecuted(address vault, bool ok);

  function addVault(address vault) external {
    require(vault != address(0), "vault=0");
    vaults.push(vault);
    emit VaultAdded(vault);
  }

  function checkUpkeep(bytes calldata)
    external
    view
    override
    returns (bool upkeepNeeded, bytes memory performData)
  {
    uint256 n = vaults.length;
    if (n == 0) return (false, "");

    address[BATCH_LIMIT] memory dueTmp;
    uint256 found;

    for (uint256 i = 0; i < SCAN_LIMIT && found < BATCH_LIMIT; i++) {
      uint256 idx = (cursor + i) % n;
      IMySwitch v = IMySwitch(vaults[idx]);
      if (v.expired() && !v.executed()) {
        dueTmp[found++] = vaults[idx];
      }
    }
    if (found == 0) return (false, "");

    address[] memory due = new address[](found);
    for (uint256 j = 0; j < found; j++) due[j] = dueTmp[j];
    return (true, abi.encode(due));
  }

  function performUpkeep(bytes calldata performData) external override {
    if (performData.length == 0) return;
    address[] memory due = abi.decode(performData, (address[]));

    uint256 steps = due.length > BATCH_LIMIT ? BATCH_LIMIT : due.length;
    for (uint256 i = 0; i < steps; i++) {
      IMySwitch v = IMySwitch(due[i]);
      if (v.expired() && !v.executed()) {
        try v.performPayout() {
          emit VaultExecuted(due[i], true);
        } catch {
          emit VaultExecuted(due[i], false);
        }
      }
    }
    uint256 n = vaults.length;
    cursor = n == 0 ? 0 : (cursor + steps) % n;
  }

  function vaultCount() external view returns (uint256) { return vaults.length; }
}

Breaking Down the SwitchManager Contract - Line by Line

1. License and Version Declaration

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
  • // SPDX-License-Identifier: MIT - Standard MIT license for open source contracts
  • pragma solidity ^0.8.24; - Specifies Solidity version 0.8.24 or higher

2. Interface Definitions

interface IMySwitch {
  function expired() external view returns (bool);
  function executed() external view returns (bool);
  function performPayout() external;
}

interface AutomationCompatibleInterface {
  function checkUpkeep(bytes calldata) external view returns (bool, bytes memory);
  function performUpkeep(bytes calldata) external;
}
  • IMySwitch - Defines the interface that vault contracts must implement
  • expired() - Returns true if the vault has passed its deadline
  • executed() - Returns true if the vault has already been paid out
  • performPayout() - Executes the actual payout to beneficiaries
  • AutomationCompatibleInterface - Required interface for Chainlink Automation integration

3. Contract Declaration and Storage

contract SwitchManager is AutomationCompatibleInterface {
  address[] public vaults;
  uint256 public constant SCAN_LIMIT = 25;
  uint256 public constant BATCH_LIMIT = 10;
  uint256 public cursor;
  • AutomationCompatibleInterface - Inherits the required automation interface
  • vaults[] - Array storing addresses of all registered vault contracts
  • SCAN_LIMIT = 25 - Maximum number of vaults to check per upkeep call
  • BATCH_LIMIT = 10 - Maximum number of vaults to execute per upkeep call
  • cursor - Tracks position for round-robin scanning to ensure fairness

4. Events

event VaultAdded(address vault);
event VaultExecuted(address vault, bool ok);
  • VaultAdded - Emitted when a new vault is registered with the manager
  • VaultExecuted - Emitted when a vault payout is attempted, with success/failure status

5. Vault Registration Function

function addVault(address vault) external {
  require(vault != address(0), "vault=0");
  vaults.push(vault);
  emit VaultAdded(vault);
}
  • addVault - Allows anyone to register a new vault contract
  • require(vault != address(0), "vault=0") - Ensures vault address is valid
  • vaults.push(vault) - Adds the vault to the registry
  • emit VaultAdded(vault) - Logs the registration for transparency

6. Chainlink Automation - checkUpkeep

function checkUpkeep(bytes calldata)
  external
  view
  override
  returns (bool upkeepNeeded, bytes memory performData)
{
  uint256 n = vaults.length;
  if (n == 0) return (false, "");

  address[BATCH_LIMIT] memory dueTmp;
  uint256 found;
  • checkUpkeep - Off-chain function that determines if upkeep is needed
  • external view override - Implements the automation interface
  • returns (bool, bytes) - Returns whether upkeep is needed and data for execution
  • address[BATCH_LIMIT] memory dueTmp - Fixed-size array to avoid dynamic allocation
  • found - Counter for how many expired vaults we've found

7. Vault Scanning Logic

for (uint256 i = 0; i < SCAN_LIMIT && found < BATCH_LIMIT; i++) {
  uint256 idx = (cursor + i) % n;
  IMySwitch v = IMySwitch(vaults[idx]);
  if (v.expired() && !v.executed()) {
    dueTmp[found++] = vaults[idx];
  }
}
  • for (uint256 i = 0; i < SCAN_LIMIT - Loop through up to 25 vaults
  • && found < BATCH_LIMIT; i++) - Stop when we find 10 expired vaults
  • (cursor + i) % n - Round-robin scanning from last position
  • IMySwitch v = IMySwitch(vaults[idx]) - Cast to interface for method calls
  • v.expired() && !v.executed() - Check if vault is expired but not yet executed
  • dueTmp[found++] = vaults[idx] - Add to list of vaults that need execution

8. Return Data Preparation

if (found == 0) return (false, "");

address[] memory due = new address[](found);
for (uint256 j = 0; j < found; j++) due[j] = dueTmp[j];
return (true, abi.encode(due));
  • if (found == 0) return (false, "") - No work needed if no expired vaults found
  • address[] memory due = new address[](found) - Create dynamic array of exact size
  • for (uint256 j = 0; j < found; j++) - Copy found vaults to dynamic array
  • return (true, abi.encode(due)) - Return success and encoded vault addresses

9. Chainlink Automation - performUpkeep

function performUpkeep(bytes calldata performData) external override {
  if (performData.length == 0) return;
  address[] memory due = abi.decode(performData, (address[]));

  uint256 steps = due.length > BATCH_LIMIT ? BATCH_LIMIT : due.length;
  • performUpkeep - On-chain function that actually executes the upkeep
  • external override - Implements the automation interface
  • if (performData.length == 0) return - Safety check for empty data
  • abi.decode(performData, (address[])) - Decode vault addresses from checkUpkeep
  • steps - Limit execution to BATCH_LIMIT to prevent gas issues

10. Vault Execution with Error Handling

for (uint256 i = 0; i < steps; i++) {
  IMySwitch v = IMySwitch(due[i]);
  if (v.expired() && !v.executed()) {
    try v.performPayout() {
      emit VaultExecuted(due[i], true);
    } catch {
      emit VaultExecuted(due[i], false);
    }
  }
}
  • for (uint256 i = 0; i < steps; i++) - Loop through vaults to execute
  • IMySwitch v = IMySwitch(due[i]) - Cast to interface for method calls
  • if (v.expired() && !v.executed()) - Double-check conditions before execution
  • try v.performPayout() - Attempt to execute the payout
  • catch - Handle any errors gracefully without stopping other executions
  • emit VaultExecuted(due[i], true/false) - Log success or failure for each vault

11. Cursor Update and Utility Function

uint256 n = vaults.length;
cursor = n == 0 ? 0 : (cursor + steps) % n;
}

function vaultCount() external view returns (uint256) { return vaults.length; }
  • cursor = n == 0 ? 0 : (cursor + steps) % n - Update cursor for next round-robin scan
  • % n - Wrap around to beginning when reaching the end
  • vaultCount() - Utility function to get total number of registered vaults

What Each Contract Does

🏦 SwitchVaultHub (The Money & Logic)

  • Stores vaults as structs: Owner, timers, balance, beneficiaries
  • Holds ETH: Actual funds are stored in this contract
  • Does the math: Calculates beneficiary shares using basis points
  • Emits events: Provides transparency for all operations
  • Implements payout rules: Contains the actual execution logic

🔗 SwitchManager (Registry + Automation Target)

  • Acts as registry/factory: Adds and keeps track of vault addresses
  • Implements Automation interface: checkUpkeep/performUpkeep for the fleet
  • One Keeper target: Chainlink Automation only needs to monitor this contract
  • Round-robin scanning: Fairly distributes scanning across all vaults
  • Batching: Prevents gas limit issues by processing in batches
  • Event emission: VaultAdded/VaultExecuted for easy indexing

Why the Hub Alone Wasn't Enough

  • Chainlink Automation needs one contract to call: Keepers won't magically "discover" standalone vaults
  • Single automation target: The manager gives Automation one address that knows all vault addresses/IDs
  • Fair scanning: Uses cursor-based round-robin to scan vaults fairly
  • Gas-bounded iteration: Batches actual work so you don't blow the gas limit
  • Separation of concerns: Hub focuses on money/state, Manager focuses on discovery and scheduling

When You Need Both vs. Just One

🔀 You Need BOTH When:

  • • Users can create separate vault contracts (clones) or you expect many hubs
  • • You want a single Chainlink upkeep to service the entire fleet
  • • You want a canonical registry your UI and subgraph can trust
  • • You need to scale beyond what fits in one contract's gas limits

✅ You Can Use ONLY the Hub When:

  • • All vaults live as structs in one contract (your current SwitchVaultHub)
  • • You register that hub directly as the Automation target
  • • You don't need to scale beyond a few hundred vaults
  • • Simplicity is more important than maximum scalability

In this architecture, the manager is optional because the hub already implements checkUpkeep/performUpkeep and has the list internally.

Why This Two-Contract Architecture Works

  • Separation of Concerns: Manager handles automation, Hub handles vault logic
  • Gas Efficiency: Manager only calls Hub when needed, Hub optimizes internal operations
  • Scalability: Multiple Hub contracts can be managed by one Manager
  • Error Isolation: If one Hub fails, others continue to work normally
  • Upgradeability: Hub logic can be updated without changing automation interface

💡 The "Aha!" Moment

After trying to build everything in a single contract, I realized that the two-contract approach was actually the right solution. The Manager handles the complexity of Chainlink Automation integration, while the Hub focuses purely on vault logic. This separation made both contracts simpler, more gas-efficient, and easier to test and maintain.

Advanced Features & Considerations

Failed Transfer Recovery

If a beneficiary can't receive ETH (e.g., contract without receive function), the funds are recorded in the owed mapping:

// Beneficiaries can claim failed transfers later
function claimOwed(uint256 vaultId) external {
  uint256 amount = hub.owed(vaultId, msg.sender);
  require(amount > 0, "nothing owed");
  hub.claimOwed(vaultId);
}

Gas Optimization Techniques

  • Fixed-size arrays: Used in checkUpkeep to avoid dynamic allocation
  • Storage references: Vault storage v instead of copying
  • Packed structs: uint32/uint64 for time values instead of uint256
  • Batch processing: Process multiple vaults per upkeep call
  • Round-robin scanning: Distribute gas load across multiple calls

Security Considerations

✅ Security Features

  • • Owner-only functions with proper modifiers
  • • Input validation on all parameters
  • • Basis points validation (must sum to 10000)
  • • Re-execution prevention (executed flag)
  • • Failed transfer recovery system

⚠️ Important Considerations

  • • Choose beneficiaries carefully
  • • Test thoroughly on testnet
  • • Monitor automation status
  • • Keep LINK balance topped up
  • • Consider multi-sig beneficiaries

Key Takeaways

Building a production-ready deadman switch taught me several important lessons about smart contract development:

  • Gas limits matter: Always consider batch processing for large datasets
  • Handle failures gracefully: Failed transfers shouldn't lose funds
  • Use appropriate data types: uint32/uint64 can save significant gas
  • Plan for scale: Round-robin approaches work better than scanning everything
  • Test edge cases: Empty vaults, failed transfers, gas limits

This multi-vault deadman switch provides a robust, scalable solution for automated fund recovery. The key innovation is the hub approach - instead of deploying separate contracts for each vault, we manage everything in a single contract with efficient batch processing.

Visual Walkthrough

Creating Your Vault

Here's what the vault creation process looks like in practice:

Creating a vault in the deadman switch system

Successful Vault Execution

When the deadman switch triggers, here's the successful payout execution:

Successful vault execution - Part 1
Successful vault execution - Part 2

Remix Development Environment

Here are some screenshots from the development process in Remix IDE:

Remix IDE - Contract compilation
Remix IDE - Contract deployment
Remix IDE - Function calls and calldata

🎯 Ready to Build Your Own?

The deadman switch system is now live and working! You can see from the screenshots that the vault creation, monitoring, and execution all work as designed. The combination of Chainlink Automation with our custom hub and manager contracts creates a robust system for automated fund recovery.

Comments

Loading comments...

Leave a Comment