Reproduced Exploit

Timeswap V2 — LiquidityToken uses `totalSupply()+1` as tokenId (collision after burn)

1. TimeswapV2LiquidityToken.mint assigns a new position's ERC-1155 tokenId with id = totalSupply() + 1. 2. totalSupply() (from ERC1155Enumerable) is _allTokens.length, which decreases when a tokenId is fully burned.

Jan 2023Otherlogic5 min read

Chain

Other

Category

logic

Date

Jan 2023

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 24902-h-02-timeswapv. Standalone Foundry PoC and full write-up: 24902-h-02-timeswapv_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/reused-identifier · vuln/accounting/tokenid-collision · vuln/loss-of-funds/balance-manipulation

Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only forge-std — no fork, no RPC, no anvil_state. Full trace: output.txt. PoC: test/24902-h-02-timeswapv_exp.sol.


Key info#

ImpactHIGH — a newly created liquidity position is assigned an ERC-1155 tokenId already in use by a live position; the two liquidities share one id, corrupting LP accounting (registry overwrite + conflated balances)
ProtocolTimeswap V2 — options/AMM liquidity token (v2-token package)
Vulnerable codeTimeswapV2LiquidityToken.mintid = totalSupply() + 1
Bug classReused identifier: a monotonically-decreasing value used as a unique id
FindingCode4rena — Timeswap, 2023-01 · #24902 · severity increased to High by judge Picodes
Reportcode4rena.com/reports/2023-01-timeswap
SourceAuditVault
StatusAudit finding — caught in review, confirmed & fixed by Timeswap (PR #293). Reproduced here as a standalone local PoC.
Compiler^0.8.8 (PoC)

This is an audit finding, not a historical on-chain incident. The PoC copies the vulnerable tokenId-assignment block of mint() verbatim and replaces the pool/option factories, reentrancy guard, mint/burn callbacks and Error.checkEnough (all irrelevant to the tokenId bug) with nothing — the liquidity amount is minted directly. A minimal ERC1155Enumerable reproduces totalSupply() == _allTokens.length.


TL;DR#

  1. TimeswapV2LiquidityToken.mint assigns a new position's ERC-1155 tokenId with id = totalSupply() + 1.
  2. totalSupply() (from ERC1155Enumerable) is _allTokens.length, which decreases when a tokenId is fully burned.
  3. Attack: mint A → id 1; mint B → id 2; burn all of A → totalSupply drops to 1; mint C → id = totalSupply()+1 = 2 — the same tokenId as the still-live B.
  4. Two distinct liquidities (B and C) now share tokenId 2. The id → position registry entry is overwritten (B's identity destroyed) and the two positions' ERC-1155 balances are conflated under one id — corrupt LP accounting the protocol cannot untangle.

The vulnerable code#

TimeswapV2LiquidityToken.mint (tokenId-assignment block copied verbatim; @> is the bug):

SOLIDITY
bytes32 key = timeswapV2LiquidityTokenPosition.toKey();
uint256 id = _timeswapV2LiquidityTokenPositionIds[key];

// if the position does not exist, create it
if (id == 0) {
    id = totalSupply() + 1;   // @> tokenId derived from a value that can DECREASE
    _timeswapV2LiquidityTokenPositions[id] = timeswapV2LiquidityTokenPosition;
    _timeswapV2LiquidityTokenPositionIds[key] = id;
}
...
_mint(param.to, id, param.liquidityAmount, bytes(""));

totalSupply() in ERC1155Enumerable:

SOLIDITY
function totalSupply() public view override returns (uint256) {
    return _allTokens.length; // shrinks when a tokenId is fully burned
}

Root cause#

totalSupply() is a live count of currently-tracked tokenIds, not a monotonic counter. Using totalSupply() + 1 as a unique id assumes the count only grows; burning a tokenId to zero removes it from _allTokens, so the next fresh position re-derives a previously-issued id. The fix is a monotonic counter: id = ++tokenIdCounter.

Note on exploitability: the deployed ERC1155Enumerable._removeTokenEnumeration checks _idTotalSupply[id] == 0 before subtracting amount, so it never actually removes a fully-burned id and totalSupply never shrinks — which masks this bug. The finding rates it HIGH because that masking is itself a defect; its PoC applies the one-line patch (decrement first, then check) to expose the collision. This synthetic uses the patched enumerable, exactly as the finding's PoC does.

Attack walkthrough#

From output.txt:

  1. Attacker mints position A (token0/1) → id = totalSupply()+1 = 1; totalSupply = 1.
  2. Victim mints position B (token2/3) → id = 2; totalSupply = 2.
  3. Attacker fully burns A → its id is removed from enumeration → totalSupply = 1.
  4. Attacker mints position C (token4/5) → id = totalSupply()+1 = 2collides with the live position B.
  5. HARM: positionIds[keyB] == positionIds[keyC] == 2; positions[2] is overwritten from B to C; idTotalSupply[2] == victimB + attackerC — two unrelated positions/holders now share one tokenId's accounting.

Diagrams#

flowchart TD A["mint A token0/1 -> id = totalSupply+1 = 1"] --> B["mint B token2/3 -> id = 2"] B --> C["burn ALL of A -> _allTokens shrinks -> totalSupply 2 -> 1"] C --> D["mint C token4/5 -> id = totalSupply+1 = 1+1 = 2"] D --> E{"id 2 already used by live position B"} E --> F["registry[2] overwritten B -> C"] E --> G["balanceOf victim,2 and attacker,2 conflated under one id"]

Remediation#

DIFF
+ uint256 public tokenIdCounter;
  ...
  if (id == 0) {
-     id = totalSupply() + 1;
+     id = ++tokenIdCounter;
      _timeswapV2LiquidityTokenPositions[id] = timeswapV2LiquidityTokenPosition;
      _timeswapV2LiquidityTokenPositionIds[key] = id;
  }

(The same fix applies to TimeswapV2Token.)

How to reproduce#

BASH
cd ~/RustroverProjects/audits/evm-hack-registry/24902-h-02-timeswapv_exp
forge test -vvv
# Fully local — no fork, no RPC, no anvil_state required.
# Expected: test PASSES — position C collides on tokenId 2 (== B's id), the
#           id->position registry is overwritten, and id-2 supply conflates B + C.

PoC source: test/24902-h-02-timeswapv_exp.sol — the verbatim vulnerable mint() tokenId-assignment plus a minimal enumerable and the finding's mint/mint/burn/mint sequence.

Note: pool/option factories, the reentrancy guard, the mint/burn callbacks and Error.checkEnough are stripped (they do not affect tokenId assignment); the liquidity amount is minted directly. The totalSupply() == _allTokens.length semantics and the vulnerable id = totalSupply() + 1 line are faithful.


Reference: finding #24902 ([H-02]) in the Code4rena Timeswap contest (Jan 2023) · curated by AuditVault


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.