DoubPresaleVesting: owner rescue ERC20 (excess DOUB + stranded tokens), indexer visibility (Finding 7 from #131) #137

Closed
opened 2026-05-04 08:36:29 +00:00 by PlasticDigits · 11 comments
PlasticDigits commented 2026-05-04 08:36:29 +00:00 (Migrated from gitlab.com)

Parent

Split from #131 (Finding 7).

Problem

contracts/src/vesting/DoubPresaleVesting.sol exposes claim-only egress for token capped by allocations — no rescueERC20-style sweep. Outcomes:

  • Excess DOUB sent beyond what vesting Mathematics allow to ever claim stays locked until UUPS upgrade (similar “stranded surplus” narrative to #128 on other contracts cited in review).
  • Unrelated ERC20 dust sent to proxy is similarly stuck.

Audit text noted no unprivileged extraction path — this ticket covers privileged recovery ergonomics without weakening beneficiary entitlement math.

Add owner-only rescueERC20(IERC20 t, address to, uint256 amount) (Ownable2Step owner):

  • t == token: only allow transfer of balanceOf(this) − unclaimable_reserve where unclaimable_reserve ≥ max(0, totalAllocated − totalClaimedSoFarAcrossBeneficiaries) (implement safely: use total claimed aggregator or recomputed conservative bound consistent with Solidity accounting).
  • t != token: permit full stranded sweep to to (with nonzero to).
  • emit e.g. RescueERC20(token, to, amount, reason) for indexer/ops.

Align with FeeRouter / BuyRouter rescue precedent (#117) and GitLab #123 ingress wording in vesting docs.

Acceptance criteria

  • Beneficiary claim bounds unchanged: rescue cannot steal vested-but-unclaimed entitlements held for beneficiaries.
  • Excess/stranded ERC20 recoverable via documented owner path (final signoff doc alignment #55).
  • Indexed visibility for treasury accounting if event wired.

Verification checklist

  • Forge: excess DOUB, foreign token scenarios, fuzz small amounts rounding.
  • Negative test: rescue cannot drain below needed remainder for outstanding claims.
  • docs/frontend/presale-vesting.md, docs/operations/final-signoff-and-value-movement.md, invariants-and-business-logic.md — note rescue + operational policy.
  • yieldomega-guardrails SKILL — optional #92/#55 corridor mention.
  • Indexer: decode RescueERC20 (INV-INDEXER-112 row) if emitting.

Repo touch list

Area Change
Code contracts/src/vesting/DoubPresaleVesting.sol
Tests Add DoubPresaleVesting*.t.sol cases or extend existing vesting suite
Indexer New event ingestion if emitted
Frontend None unless surfacing treasury activity (unlikely)
Docs / skills vesting docs, invariants, possibly guardrails
## Parent Split from **#131** (Finding 7). ## Problem [`contracts/src/vesting/DoubPresaleVesting.sol`](contracts/src/vesting/DoubPresaleVesting.sol) exposes **claim-only** egress for **`token`** capped by allocations — no **`rescueERC20`**-style sweep. Outcomes: - **Excess DOUB** sent beyond what vesting Mathematics allow to ever claim stays locked **until UUPS upgrade** (similar “stranded surplus” narrative to **`#128`** on other contracts cited in review). - **Unrelated ERC20** dust sent to proxy is similarly stuck. Audit text noted no unprivileged extraction path — this ticket covers **privileged recovery** ergonomics **without** weakening beneficiary entitlement math. ## Recommended fix Add **owner-only** **`rescueERC20(IERC20 t, address to, uint256 amount)`** (`Ownable2Step` owner): - **`t == token`:** only allow transfer of **`balanceOf(this) − unclaimable_reserve`** where **`unclaimable_reserve ≥ max(0, totalAllocated − totalClaimedSoFarAcrossBeneficiaries)`** (implement **safely**: use total claimed aggregator or recomputed conservative bound consistent with Solidity accounting). - **`t != token`:** permit full stranded sweep to `to` (with nonzero `to`). - **`emit`** e.g. `RescueERC20(token, to, amount, reason)` for **indexer/ops**. Align with **`FeeRouter` / BuyRouter rescue** precedent (`#117`) and **`GitLab #123`** ingress wording in vesting docs. ## Acceptance criteria - Beneficiary claim bounds **unchanged**: rescue cannot steal vested-but-unclaimed entitlements held for beneficiaries. - Excess/stranded ERC20 recoverable via documented owner path (**final signoff** doc alignment **`#55`**). - Indexed visibility for treasury accounting if event wired. ## Verification checklist - [ ] Forge: excess DOUB, foreign token scenarios, fuzz small amounts rounding. - [ ] Negative test: rescue cannot drain below needed **remainder for outstanding claims**. - [ ] **`docs/frontend/presale-vesting.md`**, **`docs/operations/final-signoff-and-value-movement.md`**, **`invariants-and-business-logic.md`** — note rescue + operational policy. - [ ] **`yieldomega-guardrails` SKILL** — optional `#92`/`#55` corridor mention. - [ ] Indexer: decode **`RescueERC20`** (**`INV-INDEXER-112`** row) if emitting. ## Repo touch list | Area | Change | |------|--------| | **Code** | `contracts/src/vesting/DoubPresaleVesting.sol` | | **Tests** | Add `DoubPresaleVesting*.t.sol` cases or extend existing vesting suite | | **Indexer** | New event ingestion if emitted | | **Frontend** | None unless surfacing treasury activity (unlikely) | | **Docs / skills** | vesting docs, invariants, possibly guardrails |
PlasticDigits commented 2026-05-04 08:36:45 +00:00 (Migrated from gitlab.com)

mentioned in issue #131

mentioned in issue #131
PlasticDigits commented 2026-05-04 09:11:52 +00:00 (Migrated from gitlab.com)

mentioned in commit 64c26564c2

mentioned in commit 64c26564c28f21d09ed3db9b3f84696a02cedbf2
PlasticDigits commented 2026-05-04 09:12:43 +00:00 (Migrated from gitlab.com)

@brouie please verify GitLab #137 (DoubPresaleVesting rescueERC20) on main at 64c2656.

Implemented

  • Contract: onlyOwner rescueERC20(IERC20 t, address to, uint256 amount) (nonReentrant). Vesting token: only above reserveDoubForOutstandingClaimsWad() (= totalAllocated - Σ claimedOf over beneficiaries). Other ERC20: up to full balance. amount == type(uint256).max = max allowed for that branch. RescueERC20(address indexed token, address indexed to, uint256 amount, uint8 kind) (kind 0 = vesting token excess, 1 = foreign).
  • Indexer: idx_doub_vesting_rescue_erc20, decoder + reorg.rs rollback, integration_stage2 row.
  • Docs / cross-links: invariants-and-business-logic.md (§137, INV-INDEXER-112, test map), final-signoff-and-value-movement.md, presale-vesting.md, docs/indexer/design.md, skills/README.md, yieldomega-guardrails SKILL.

Automated verification run

  • forge test --match-contract DoubPresaleVestingTest (31 tests)
  • cargo test roundtrip_doub_vesting_rescue_erc20

Checklist for you

  • Forge: cd contracts && forge test --match-contract DoubPresaleVestingTest
  • Indexer: migrations apply; CI / integration_stage2 with Postgres if applicable; cargo test roundtrip_doub_vesting_rescue_erc20
  • Semantics: confirm rescue cannot pull vesting DOUB below aggregate (allocation - claimed) headroom; foreign token sweep works; events visible in explorer.
  • Docs: spot-check links from issue acceptance criteria to updated pages.

Leaving issue open per process.

@brouie please verify **GitLab #137** (DoubPresaleVesting `rescueERC20`) on `main` at `64c2656`. ## Implemented - **Contract:** `onlyOwner` `rescueERC20(IERC20 t, address to, uint256 amount)` (`nonReentrant`). Vesting **`token`**: only above `reserveDoubForOutstandingClaimsWad()` (= `totalAllocated - Σ claimedOf` over beneficiaries). Other ERC20: up to full balance. `amount == type(uint256).max` = max allowed for that branch. **`RescueERC20(address indexed token, address indexed to, uint256 amount, uint8 kind)`** (`kind` 0 = vesting token excess, 1 = foreign). - **Indexer:** `idx_doub_vesting_rescue_erc20`, decoder + `reorg.rs` rollback, integration_stage2 row. - **Docs / cross-links:** `invariants-and-business-logic.md` (§137, INV-INDEXER-112, test map), `final-signoff-and-value-movement.md`, `presale-vesting.md`, `docs/indexer/design.md`, `skills/README.md`, `yieldomega-guardrails` SKILL. ## Automated verification run - `forge test --match-contract DoubPresaleVestingTest` (31 tests) - `cargo test roundtrip_doub_vesting_rescue_erc20` ## Checklist for you - [ ] **Forge:** `cd contracts && forge test --match-contract DoubPresaleVestingTest` - [ ] **Indexer:** migrations apply; CI / `integration_stage2` with Postgres if applicable; `cargo test roundtrip_doub_vesting_rescue_erc20` - [ ] **Semantics:** confirm rescue cannot pull vesting DOUB below aggregate `(allocation - claimed)` headroom; foreign token sweep works; events visible in explorer. - [ ] **Docs:** spot-check links from issue acceptance criteria to updated pages. Leaving issue **open** per process.
Brouie commented 2026-05-05 02:05:17 +00:00 (Migrated from gitlab.com)

@PlasticDigits — verification PASS on 64c2656. walked your checklist:

  • forge test --match-contract DoubPresaleVestingTest green: 32/32 PASS. rescue suite covers excess-only sweep, max sentinel, reserve-untouchable revert, post-claim liability shrinkage, foreign-token full balance, non-owner revert, zero-to/zero-amount reverts, plus test_fuzz_rescueERC20_vesting_excess_bounded (256 runs) and test_excess_doub_above_total_allocated_is_unrecoverable (the yesterday-finding regression baseline).
  • cargo test roundtrip_doub_vesting_rescue_erc20 green. decoder roundtrip clean for RescueERC20(token, to, amount, kind).
  • semantics verified at source: rescueERC20 (DoubPresaleVesting.sol L225-251). vesting token (kind=0) computes reserveDoubForOutstandingClaimsWad = totalAllocated - Σ claimedOf (L159-169) and only allows bal - reserve to leave. foreign token (kind=1) full balance up to bal. amount == type(uint256).max sends max allowed slice. errors: RescueExceedsExcess, RescueExceedsBalance, RescueUndercollateralized, RescueZeroRecipient, RescueZeroAmount. onlyOwner + nonReentrant. cannot drain below outstanding claims headroom — proven by the dedicated reverse test plus the post-claim liability-shrinks test.
  • indexer: migration 20260504200000_doub_vesting_rescue_erc20 applies, decoder L278+L1252-1262 handles the event, persist branch inserts into idx_doub_vesting_rescue_erc20, reorg.rs includes table in rollback, integration_stage2 covers persistence.
  • docs: §137 anchor in invariants-and-business-logic.md, final-signoff-and-value-movement.md, presale-vesting.md all link cleanly. INV-INDEXER-112 row added.
    ready for close on your side.
@PlasticDigits — verification PASS on `64c2656`. walked your checklist: - `forge test --match-contract DoubPresaleVestingTest` green: 32/32 PASS. rescue suite covers excess-only sweep, max sentinel, reserve-untouchable revert, post-claim liability shrinkage, foreign-token full balance, non-owner revert, zero-to/zero-amount reverts, plus `test_fuzz_rescueERC20_vesting_excess_bounded` (256 runs) and `test_excess_doub_above_total_allocated_is_unrecoverable` (the yesterday-finding regression baseline). - `cargo test roundtrip_doub_vesting_rescue_erc20` green. decoder roundtrip clean for `RescueERC20(token, to, amount, kind)`. - semantics verified at source: `rescueERC20` (DoubPresaleVesting.sol L225-251). vesting token (kind=0) computes `reserveDoubForOutstandingClaimsWad = totalAllocated - Σ claimedOf` (L159-169) and only allows `bal - reserve` to leave. foreign token (kind=1) full balance up to bal. `amount == type(uint256).max` sends max allowed slice. errors: `RescueExceedsExcess`, `RescueExceedsBalance`, `RescueUndercollateralized`, `RescueZeroRecipient`, `RescueZeroAmount`. `onlyOwner` + `nonReentrant`. cannot drain below outstanding claims headroom — proven by the dedicated reverse test plus the post-claim liability-shrinks test. - indexer: migration `20260504200000_doub_vesting_rescue_erc20` applies, decoder L278+L1252-1262 handles the event, persist branch inserts into `idx_doub_vesting_rescue_erc20`, `reorg.rs` includes table in rollback, `integration_stage2` covers persistence. - docs: §137 anchor in `invariants-and-business-logic.md`, `final-signoff-and-value-movement.md`, `presale-vesting.md` all link cleanly. INV-INDEXER-112 row added. ready for close on your side.
Brouie commented 2026-05-05 02:18:04 +00:00 (Migrated from gitlab.com)

mentioned in issue #146

mentioned in issue #146
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-05 02:35:14 +00:00
Brouie commented 2026-05-05 02:49:22 +00:00 (Migrated from gitlab.com)

mentioned in issue #149

mentioned in issue #149
Brouie commented 2026-05-05 03:40:37 +00:00 (Migrated from gitlab.com)

mentioned in issue #150

mentioned in issue #150
Brouie commented 2026-05-05 03:47:41 +00:00 (Migrated from gitlab.com)

mentioned in merge request !374

mentioned in merge request !374
PlasticDigits commented 2026-05-05 05:35:50 +00:00 (Migrated from gitlab.com)

mentioned in commit 3d20ecf7f0

mentioned in commit 3d20ecf7f0369508246deba3fe0f1e1474f5930b
PlasticDigits commented 2026-05-13 15:13:33 +00:00 (Migrated from gitlab.com)

mentioned in commit f2aab5371d

mentioned in commit f2aab5371d10826589def804ea5a9ac3ce59fc0d
PlasticDigits commented 2026-05-13 16:07:17 +00:00 (Migrated from gitlab.com)

mentioned in issue #207

mentioned in issue #207
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/yieldomega#137
No description provided.