"secured by 19 guardians." "protected by a 48h timelock." "governed by a multisig."
claims. not evidence.
$2.8B drained from bridges since 2022. every one had a security page. so i built a framework to check what's actually on-chain. every number here is reproducible with cast and an rpc endpoint. no trust required.
five layers. each scored 0–4. composite score = minimum of all five. attackers target the weakest layer, not the strongest one.
| layer | protects | failed at |
|---|---|---|
| L1 verification | message authenticity | Wormhole $326M |
| L2 independence | validator diversity | Ronin $625M |
| L3 upgrade | contract mutation | Nomad $190M |
| L4 timelock | reaction window | Harmony $100M |
| L5 circuit breaker | drain velocity | Bybit $1.46B |
security is multiplicative. one zero makes the product zero.
how does the destination chain know a message is real?
| 0 | single signer |
| 1 | multisig < 5 |
| 2 | 5–12 independent validators |
| 3 | 13+ validators or multi-method ISM |
| 4 | zk light client — no trusted third party |
the thing nobody talks about: LayerZero V2 DVN config is per pathway. a bridge can run 2-of-3 DVN on ethereum→arbitrum and 1-of-1 on ethereum→some-smaller-chain. docs never disclose this at the pathway level.
# read DVN config for a specific oapp + destination
cast call <SEND_LIBRARY> \
"getUlnConfig(address,uint32)((uint64,uint8,uint8,uint8,address[],address[]))" \
<OAPP_ADDRESS> <DST_EID>
# requiredDVNCount=1, optionalDVNCount=0 → single point of failure
wormhole has 19 guardians, 13-of-19 quorum. sounds great. but in jan 2024, a researcher disclosed that the genesis guardian set — one single key — was never expired on wormchain. one key could bypass the entire quorum on that chain. the key was allegedly destroyed. "allegedly" is not a security model. fixed within 48h after disclosure, but existed for years.
ronin's 5-of-9 is the case study everyone cites but nobody fully explains. mechanism: 4 of 9 validators were sky mavis infrastructure. the 5th was a residual signing allowlist from november 2021 — never revoked. compromising one dev's laptop yielded 5 of 9 keys.
| 0 | all keys, one org |
| 1 | < 3 independent orgs |
| 2 | 3–7 orgs |
| 3 | 8+ publicly identified orgs |
| 4 | permissionless with slashing |
on-chain heuristic worth running: check if validator addresses share a common funding source. same deployer → likely same org.
trace the full authority chain. most people stop at "it's a multisig." don't.
proxy → EIP-1967 admin slot → ProxyAdmin → owner()
→ timelock? → proposer role → multisig → threshold/owners
# who controls upgrades?
cast storage <PROXY> \
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
# eoa or contract?
cast code <ADMIN> | head -c 10
# "0x" = EOA = score 0. anything else = dig deeper
# if multisig:
cast call <ADMIN> "getThreshold()(uint256)"
cast call <ADMIN> "getOwners()(address[])"
| 0 | EOA |
| 1 | multisig ≤ 2 or timelock < 1h |
| 2 | multisig 3+ with 1–24h timelock |
| 3 | multisig 4+ with 24h+ timelock |
| 4 | on-chain governance + 48h+, or immutable |
the number that matters isn't the delay. it's who can change the delay.
cast call <TIMELOCK> "getMinDelay()(uint256)"
# 86400 = 24h. good.
# but can the admin zero it out?
cast call <TIMELOCK> "hasRole(bytes32,address)(bool)" \
$(cast keccak "TIMELOCK_ADMIN_ROLE") <ADMIN>
# true = the timelock is theater
a timelock whose delay can be zeroed without delay is equivalent to no timelock.
bybit lost $1.46B in one batch. no rate limit. no pause. nothing fired.
cast call <BRIDGE> "paused()(bool)" 2>/dev/null
most bridges score 0 here. it's the least deployed layer and the one that would've mattered most.
asymmetric pathway. strong on flagship routes, 1-of-1 on secondary chains. the attacker doesn't need your best-secured pathway. they'll use the worst one.
timelock illusion. 48h timelock exists. admin holds TIMELOCK_ADMIN_ROLE. admin calls updateDelay(0). effective timelock: zero.
orphaned guardian. validator set rotated on mainnet. old set never expired on alt-L1. one old key bypasses the new quorum. wormchain bug was exactly this — disclosed jan 2024, existed for years.
the safe blind spot. everyone checks threshold and owners. nobody checks modules.
# modules can exec transactions WITHOUT meeting threshold
cast call <SAFE> "getModules()(address[])"
# non-empty → investigate immediately
# guard (additional tx validation — 0x00 means no guard)
cast storage <SAFE> \
0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8
bybit's safe had 3-of-N threshold. the attacker didn't break it — they used operation: 1 (delegatecall) in execTransaction. signers approved what looked like a normal transfer. they actually signed a full wallet takeover. the threshold held. it just didn't matter.
five commands. tells you more than the security page.
B="<BRIDGE_PROXY>"
# 1. upgrade admin
cast storage $B \
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
# 2. eoa or contract?
cast code <ADMIN> | head -c 10
# 3. multisig threshold
cast call <ADMIN> "getThreshold()(uint256)" 2>/dev/null
# 4. timelock delay
cast call <ADMIN> "getMinDelay()(uint256)" 2>/dev/null
# 5. pausable?
cast call $B "paused()(bool)" 2>/dev/null
if the admin is an eoa, you already know more than the documentation told you.
BTS measures operational security around contracts, not correctness of contracts. doesn't model economic security, liveness, or off-chain key management. measures what the chain can prove. a floor, not a ceiling.
if your bridge's security can't be verified on-chain, it isn't security. it's marketing.
bridge-security-toolkit automates these checks. PRs welcome.
written between mass transits and mass caffeine somewhere in the pnw. — 0xrivet