CMPGFB/Dead-Man-Switch-Contracts ·
Commit: b2840d4 (2025-03-12) ·
Audited: February 21, 2026
Dead Man's Switch is a two-contract system on the Stacks blockchain that allows a user to set up automatic asset transfer to a beneficiary if the owner fails to check in within a configurable timeout period. The system supports STX, SIP-010 tokens, and includes a second contract for Bitcoin UTXO integration.
The codebase spans 2 Clarity files (~300 lines total). The main contract (deadmanswitch.clar) handles STX and SIP-010 token custody with initialization, check-in, deposits, withdrawals, and beneficiary claims. The UTXO variant (utxodeadmanswitch.clar) attempts Bitcoin UTXO integration but uses non-existent Clarity functions.
| Metric | Weight | Score | Weighted |
|---|---|---|---|
| Financial risk | 3 | 3 — Holds STX + SIP-010 tokens, asset custody with timed release | 9 |
| Deployment likelihood | 2 | 2 — Has README, multi-chain versions (Clarity + Solidity + Miniscript) | 4 |
| Code complexity | 2 | 2 — 300 lines, 2 contracts, trait usage, signature patterns | 4 |
| User exposure | 1.5 | 1 — 2 stars | 1.5 |
| Novelty | 1.5 | 3 — Dead man's switch — unique mechanism, first audit of this category | 4.5 |
| Total | 23 / 10 = 2.3 ≥ 1.8 ✓ | ||
No Clarity version specified (pre-Clarity 4). Uses legacy as-contract throughout.
| ID | Severity | Title |
|---|---|---|
| C-01 | CRITICAL | Anyone Can Front-Run Initialization to Hijack Contract |
| C-02 | CRITICAL | UTXO Contract Uses Non-Existent send-bitcoin Function |
| H-01 | HIGH | Owner Can Drain All Funds After Timeout Without Beneficiary Knowledge |
| H-02 | HIGH | Token Allowlist Not Enforced on Withdrawal or Claim |
| M-01 | MEDIUM | Owner Can Change Beneficiary and Timeout After Expiry |
| M-02 | MEDIUM | Transfer-Ownership Resets Dead Man's Switch Guarantees |
| M-03 | MEDIUM | Single-Use Contract — Only One Dead Man's Switch Per Deployment |
| L-01 | LOW | UTXO Contract Has Inconsistent Initial Owner |
| L-02 | LOW | contract-event Is Public and Callable by Anyone |
| I-01 | INFO | No Event Emission in UTXO Contract |
| I-02 | INFO | principal-to-string and to-string Are Not Standard Clarity Functions |
Location: deadmanswitch.clar — initialize function
Description: The contract uses a two-step pattern: deploy first, then call initialize to set the owner, beneficiary, and timeout. Since initialize is a public function with no access control (anyone can call it as long as initialized is false), an attacker who monitors the mempool can front-run the deployer's initialization transaction.
The attacker calls initialize with themselves as tx-sender (which becomes the owner) and their own address as the beneficiary. When the deployer's initialize arrives, it fails with ERR-ALREADY-INITIALIZED. The attacker now owns the contract and is also the beneficiary.
(define-public (initialize (new-beneficiary principal) (time-blocks uint))
(begin
(asserts! (not (var-get initialized)) ERR-ALREADY-INITIALIZED)
;; No access control — anyone can be the first caller
(var-set owner tx-sender) ;; Attacker becomes owner
...
Impact: Complete contract takeover. Any STX or tokens deposited after deployment can be drained by the attacker via withdraw-stx or withdraw-token. The intended owner has no recourse.
Recommendation: Keep the initialize pattern but guard it properly: set (define-constant deployer tx-sender) at deploy time, then restrict initialize to only the deployer and ensure it can only be called once. This preserves deployment-agnostic code — the same audited contract can be reused across deployments and verified with Clarity 4's contract-hash?.
send-bitcoin FunctionLocation: utxodeadmanswitch.clar — trigger-switch function
Description: The trigger-switch function calls send-bitcoin, which does not exist in Clarity. Clarity is a smart contract language on the Stacks L2 — it can read Bitcoin state (via get-burn-block-info?) but cannot create Bitcoin transactions. This entire contract is non-functional.
(unwrap! (send-bitcoin
(unwrap! btc-recipient "Invalid BTC address")
(unwrap! btc-utxo-data.value ERR-NO-BITCOIN-UTXO)))
Additionally, the function uses string error messages ((err "BTC beneficiary not set")) which are invalid in Clarity — error responses must be (err uint) or (err bool). The unwrap! on btc-utxo-data.value is also incorrect — .value on a tuple field returns a uint, not an optional. This contract would fail to deploy.
Impact: The UTXO contract cannot compile, cannot deploy, and even if it could, cannot send Bitcoin. It's entirely non-functional. The register-btc-utxo function misleadingly suggests UTXO tracking capability that doesn't exist.
Recommendation: Remove the UTXO contract entirely, or redesign it to use an off-chain relay/bridge pattern. The dead man's switch for Bitcoin would need to use pre-signed transactions held off-chain, or a HTLC/multisig pattern at the Bitcoin script level (as the Miniscript version in this repo attempts).
Location: deadmanswitch.clar — withdraw-stx, withdraw-token, update-beneficiary, update-timeout
Description: The owner retains full control of all funds even after the timeout has expired. There is no state transition that locks the owner out when the switch triggers. The owner can:
withdraw-stx to drain all STX even after expirywithdraw-token to drain all tokens even after expiryupdate-beneficiary to change the beneficiary to themselvesupdate-timeout to extend the timeout indefinitelycheck-in to reset the timerThis means the dead man's switch doesn't actually enforce anything — the owner can always override it. The "dead" state should disable owner functions.
;; Owner can withdraw even after expiry — no is-expired check
(define-public (withdraw-stx (amount uint))
(begin
(try! (assert-initialized))
(try! (assert-owner))
;; Missing: (asserts! (not (is-expired)) ERR-TIMEOUT-REACHED)
...
Impact: The core promise of the dead man's switch is broken. The beneficiary has no guarantee they'll receive funds because the owner can always drain, redirect, or reset. In a real scenario where the owner is incapacitated but not dead (e.g., hospitalized with device access), someone with the owner's keys could drain everything before the beneficiary claims.
Recommendation: Add an expiry check to all owner-only functions: (asserts! (not (is-expired)) ERR-SWITCH-TRIGGERED). Once the timeout passes, only beneficiary claim functions should work. The owner's recourse should be limited to check-in (which resets the timer) while the switch is still active.
Location: deadmanswitch.clar — allowed-tokens map, withdraw-token, claim-token
Description: The contract maintains an allowed-tokens map and an allow-token function to register approved token contracts. However, neither withdraw-token nor claim-token checks this map. Any SIP-010 token contract can be passed, including malicious ones.
(define-map allowed-tokens principal bool)
;; allow-token sets the allowlist...
(define-public (allow-token (token-contract principal))
(begin
(try! (assert-initialized))
(try! (assert-owner))
(map-set allowed-tokens token-contract true)
...))
;; ...but withdraw-token and claim-token never check it
(define-public (withdraw-token (token-contract <sip-010-trait>) (amount uint))
(begin
(try! (assert-initialized))
(try! (assert-owner))
;; Missing: (asserts! (default-to false (map-get? allowed-tokens (contract-of token-contract))) ERR-TOKEN-NOT-ALLOWED)
(try! (as-contract (contract-call? token-contract transfer ...)))
...
Impact: A malicious token contract could be passed that executes arbitrary logic in its transfer function (e.g., re-entering the dead man's switch contract, emitting fake events, or calling other contracts with the switch contract's authority via as-contract). The allowlist feature gives a false sense of security.
Recommendation: Add allowlist validation: (asserts! (default-to false (map-get? allowed-tokens (contract-of token-contract))) ERR-TOKEN-NOT-ALLOWED) to both withdraw-token and claim-token. In Clarity 4, an even stronger approach is to use as-contract? with explicit asset allowances (with-ft, with-nft) — this enforces at the language level which tokens the contract can move, making the allowlist redundant and eliminating the risk of malicious token contracts executing arbitrary logic under as-contract authority.
Location: deadmanswitch.clar — update-beneficiary, update-timeout
Description: Even after the dead man's switch has "triggered" (timeout expired), the owner can change the beneficiary to a different address or extend the timeout. This creates a race condition: the beneficiary sees the switch is expired and prepares to claim, but the owner (or someone with the owner's keys) changes the beneficiary at the last moment.
Impact: Beneficiary's claim can be front-run by a beneficiary-change transaction. The timeout extension can retroactively "un-trigger" the switch, denying the beneficiary access to funds they were promised.
Recommendation: Freeze all owner configuration functions once is-expired returns true. Only check-in should work (to reset the timer while the owner is still alive).
Location: deadmanswitch.clar — transfer-ownership
Description: The transfer-ownership function changes the owner to a new principal, but does not reset the last-check-in timer. The new owner inherits the previous owner's check-in state. If the previous owner checked in recently, the new owner has a full timeout period. If the previous owner was inactive, the new owner might immediately be in an expired state. Conversely, the new owner might be a contract or an unreachable address, permanently bricking the dead man's switch.
Impact: Unexpected timing behavior after ownership transfer. The new owner might not realize they need to check in immediately, or the transfer could be used to extend the switch by having a fresh principal "take over."
Recommendation: Reset last-check-in to block-height on ownership transfer, and consider requiring the new owner to explicitly accept the transfer.
Location: deadmanswitch.clar — overall architecture
Description: The contract uses global data variables (owner, beneficiary, timeout, etc.) instead of a map-based design. This means only one dead man's switch can exist per deployed contract instance. Each user who wants a dead man's switch must deploy their own copy of the contract, paying full deployment costs.
Impact: Poor scalability and high cost per user. Each switch requires a separate contract deployment (~0.5 STX+ on mainnet). A factory pattern with a map of switches would allow unlimited users on a single deployment.
Recommendation: Refactor to use a define-map keyed by switch ID or owner principal, with a create function that initializes new entries. This allows multiple switches per contract deployment.
Location: utxodeadmanswitch.clar — data variable declarations
Description: The main contract initializes owner to a zero address ('ST000000000000000000000000000000000000000), preventing pre-init owner actions. The UTXO contract initializes owner to tx-sender (the deployer), but the check-in function checks is-eq tx-sender (var-get owner) without requiring initialization. This means the deployer can check in before the contract is initialized.
;; Main contract: safe — zero address
(define-data-var owner principal 'ST000000000000000000000000000000000000000)
;; UTXO contract: unsafe — deployer is owner immediately
(define-data-var owner principal tx-sender)
Impact: Minor inconsistency. The UTXO contract allows pre-initialization check-ins, but since the contract can't deploy anyway (C-02), the practical impact is nil.
Recommendation: Use consistent initialization patterns across both contracts.
contract-event Is Public and Callable by AnyoneLocation: deadmanswitch.clar — contract-event function
Description: The contract-event function is defined as define-public with no access control. Anyone can call it to emit arbitrary event data that appears to come from the contract.
(define-public (contract-event (event-type (string-ascii 50)) (event-data (string-utf8 500)))
(ok true))
Impact: Fake events can confuse off-chain indexers or UIs monitoring the contract. An attacker could emit fake "check-in", "claim-stx", or "transfer-ownership" events to mislead watchers.
Recommendation: Make this function define-private since it's only used internally, or add an owner/beneficiary check.
Location: utxodeadmanswitch.clar
Description: The main contract emits events via contract-event for all state changes. The UTXO contract emits no events at all, making it impossible for off-chain systems to track state changes.
Recommendation: Add print statements (the standard Clarity pattern) for all state mutations.
principal-to-string and to-string Are Not Standard Clarity FunctionsLocation: deadmanswitch.clar — multiple event calls
Description: The contract uses principal->string and to-string in event data construction. These are not standard Clarity built-in functions. principal->string doesn't exist in any Clarity version. This would cause deployment failure.
(try! (contract-event "initialize"
(concat (concat "owner: " (principal->string tx-sender))
(concat ", beneficiary: " (principal->string new-beneficiary)))))
Impact: The contract cannot deploy as-is. All functions that emit events would fail at the event call.
Recommendation: Use (print { event: "initialize", owner: tx-sender, beneficiary: new-beneficiary }) instead of string concatenation. The print function accepts any Clarity value and is the standard event pattern.
initialize with (define-constant deployer tx-sender) and a one-time flag — not to eliminate it. Keeping deployment-agnostic code means the same audited contract can be reused and verified via Clarity 4's contract-hash?.as-contract for custody transfers. It wraps outbound STX and token transfers in as-contract blocks, which is the right pattern for a custodial contract. However, it should migrate to Clarity 4's as-contract? with explicit asset allowances.sip-010-trait inline instead of importing the canonical SIP-010 trait. This works but means the trait signature may drift from the standard.initialize with (define-constant deployer tx-sender) + one-time flag — keeps code deployment-agnostic and verifiable via contract-hash?withdraw-token and claim-tokenas-contract? with explicit asset restrictionsprincipal->string/to-string with print for events