F-2026-0009·unbounded-traversal

Gas DoS via Key Chain Traversal

Fixedbridgecross-chainkey-registrygithub.com/pdxwebdev/yadakeyeventwallet
TL;DR

getLatestChainEntry runs two O(N) traversals plus a fixed 1000-element memory allocation on every call, costing ~900k gas after only 50 rotations and growing unboundedly toward the block gas limit.

Severity
MEDIUM
Impact
MEDIUM
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
LOW
02Section · Description

Description

getLatestChainEntry calls buildFromPublicKey → _buildChainFromHash, which performs two O(N) traversals every time it is called:

  1. Backward traversal (lines 261-282): follows prevPublicKeyHash links to find inception.
  2. Forward traversal (lines 289-303): follows prerotatedKeyHash links from inception to the latest entry.

Additionally, _buildChainFromHash allocates a fixed KeyLogEntry[](1000) array in memory on every call (line 253), even though the Bridge only ever needs the latest entry (log[log.length - 1]). The full array is never consumed by any on-chain caller, Bridge.sol calls only getLatestChainEntry.

Measured cost: a single getLatestChainEntry call costs between 700k–900k gas at chain length 101 when called from the last key (key chain length: each pair rotation adds 2 entries, so 50 rotations = chain length 101 including inception). The cost depends on the starting key's position in the chain. The Bridge passes ctx.unconfirmed.publicKey, a key near the end, requiring near-full backward traversal before the forward traversal.

Vulnerable Scenario

  1. A user registers key inception via registerKeyLog, chain length is now 1.
  2. The user performs 50 key pair rotations via registerKeyLogPair, each rotation adds 2 entries (unconfirmed + confirming), growing the chain to 101 entries.
  3. The user calls any bridge operation (e.g., registerKeyPairWithTransfer to wrap tokens). Internally, the Bridge calls getLatestChainEntry(ctx.unconfirmed.publicKey) (line 512).
  4. _buildChainFromHash receives the unconfirmed key (near the end of the chain) and:
    • Traverses backward through ~100 entries to find inception (~100 SLOADs)
    • Allocates KeyLogEntry[](1000) in memory
    • Traverses forward through all 100 entries to find the latest (~100 SLOADs)
    • Allocates a result array and copies all entries
  5. Total cost: ~900k gas for a single lookup, just to retrieve the latest entry.
  6. With continued rotations, gas cost grows by ~4,400 per additional entry, eventually making bridge operations impractical or exceeding the block gas limit.
03Section · Impact

Impact

  • Every bridge operation (wrap, unwrap, transfer, key rotation) pays O(N) gas for key chain lookup.
  • Users with long key chains eventually cannot perform any operations.
  • If the owner's chain grows too long, the bridge becomes unmanageable.
  • At ~1000 entries, chains hit the hardcoded array limit and silently truncate (see [F-2026-0014]).
04Section · Recommendation

Recommendation

Maintain two index mappings, updated during registerKeyLog / registerKeyLogPair, to make all lookups O(1):

solidity
mapping(address => address) public chainOf; // any key hash → inception hash
mapping(address => address) public latestInChain; // inception hash → latest key hash

This eliminates both traversals: given any mid-chain key, chainOf resolves to inception in O(1), and latestInChain resolves to the latest entry in O(1). The fixed 1000-element array allocation also becomes unnecessary.

05Section · Resolution

Resolution

YadaCoin, Confirmed.

Zealynx, Fixed.

Status
Fixed
F-2026-0009

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx