Chainlink Automation: Building a Deadman Switch for Emergency Fund Recovery

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 contractspragma 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 beneficiariesuint32 graceAmount of time after the heartbeat to allow for late check-insuint32 heartbeatAmount of time between check-ins and uint32 to saves gas compared to uint256uint64 lastHeartbeatLast time user checks in and uint64for timestamps is sufficient and more gas-efficientbool executedIndicates if the vault has been executed by returning true or falseuint256 balanceAmount of ETH in the vaultBeneficiary[] bensArray 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 performvaults[]- Dynamic array storing all vaultsMapping- used to map through addresses and their corresponding valuesowed- Nested mapping tracking failed transfers (that are still owed)for later claimingcursor- Tracks where to start scanning, beginning from last stopped positionSCAN_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 ownerDeposited- Emitted when a deposit is madeCheckedIn- Emitted when a check-in is made along with the vault ID and next deadlinePayout- Emitted when a payout is made along with the vault ID, recipient, amount, and if the transfer succeededExecuted- Emitted when a vault is executed along with the vault ID and timestampOwedClaimed- 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 parametersrecipients- Array of addresses of the beneficiariesbps- Array of basis points of the beneficiariesheartbeatSec- Heartbeat in secondsgraceSec- Grace period in secondspayable- Allows the function to receive ETHrequire- 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 IDvaults.push()- Adds a new empty vault to the arrayVault storage v- Creates a storage reference for gas efficiencylastHeartbeat- 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 arrayChainlink 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.
checkUpkeepis the function that monitors the vaults and checks if they are expired and need to be executed.externalis used to indicate that the function is external.overrideis used to override the function in theIAutomationinterface.bytes calldatais the data passed to the function.viewis used to indicate that the function does not modify the state of the contract.returnsis used to return the data from the function.upkeepNeededis a boolean that indicates if upkeep is needed.performDatais 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 vaultsi++- increment the loopfound- Number of expired vaults foundBATCH_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 withrequire(!v.executed, "executed");- checks if the vault has already been executedrequire(block.timestamp >= nextDeadline(id), "not expired");- checks if the vault has expiredv.executed = true;- marks the vault as executeduint256 bal = v.balance;- gets the balance of the vaultv.balance = 0;- sets the balance of the vault to 0if (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
owedmapping 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
Deploy and Register
Deploy the hub contract and register it with Chainlink Automation.
Create Vaults
Call createVault() with your beneficiaries, percentages, and timing parameters.
Deposit Funds
Send ETH to specific vaults using deposit(vaultId).
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 contractspragma 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 implementexpired()- Returns true if the vault has passed its deadlineexecuted()- Returns true if the vault has already been paid outperformPayout()- Executes the actual payout to beneficiariesAutomationCompatibleInterface- 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 interfacevaults[]- Array storing addresses of all registered vault contractsSCAN_LIMIT = 25- Maximum number of vaults to check per upkeep callBATCH_LIMIT = 10- Maximum number of vaults to execute per upkeep callcursor- 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 managerVaultExecuted- 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 contractrequire(vault != address(0), "vault=0")- Ensures vault address is validvaults.push(vault)- Adds the vault to the registryemit 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 neededexternal view override- Implements the automation interfacereturns (bool, bytes)- Returns whether upkeep is needed and data for executionaddress[BATCH_LIMIT] memory dueTmp- Fixed-size array to avoid dynamic allocationfound- 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 positionIMySwitch v = IMySwitch(vaults[idx])- Cast to interface for method callsv.expired() && !v.executed()- Check if vault is expired but not yet executeddueTmp[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 foundaddress[] memory due = new address[](found)- Create dynamic array of exact sizefor (uint256 j = 0; j < found; j++)- Copy found vaults to dynamic arrayreturn (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 upkeepexternal override- Implements the automation interfaceif (performData.length == 0) return- Safety check for empty dataabi.decode(performData, (address[]))- Decode vault addresses from checkUpkeepsteps- 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 executeIMySwitch v = IMySwitch(due[i])- Cast to interface for method callsif (v.expired() && !v.executed())- Double-check conditions before executiontry v.performPayout()- Attempt to execute the payoutcatch- Handle any errors gracefully without stopping other executionsemit 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 endvaultCount()- 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 vinstead 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:

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


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



🎯 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.