Skip to content

feat: recovery of excess funds#180

Draft
michael1011 wants to merge 1 commit into
masterfrom
transfer-recovery
Draft

feat: recovery of excess funds#180
michael1011 wants to merge 1 commit into
masterfrom
transfer-recovery

Conversation

@michael1011

@michael1011 michael1011 commented Apr 22, 2026

Copy link
Copy Markdown
Member

Closes #177

Summary by CodeRabbit

Release Notes

  • New Features

    • Added ownership model with two-step transfer process for swap contracts.
    • Added sweep functions to recover excess tokens and ether.
    • Introduced locked amount tracking for active swaps.
  • Chores

    • Contract version bumped to 7.
    • Updated deployment initialization to require owner address.

@michael1011 michael1011 requested a review from maybeast April 22, 2026 09:49
@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR implements per-token and per-Ether accounting in swap contracts to track locked amounts for outstanding swaps. The contracts now inherit from Ownable2Step, add lockedAmounts/lockedEther state variables, update tracking on lock and swap-ending paths, and introduce owner-only sweep functions to withdraw excess tokens and Ether. Contract version bumped from 6 to 7.

Changes

Cohort / File(s) Summary
Core Swap Contracts
contracts/ERC20Swap.sol, contracts/EtherSwap.sol
Added Ownable2Step inheritance, per-token/per-Ether locked amount tracking via lockedAmounts/lockedEther mapping/variable, owner-only sweepToken and sweepEther functions, new NoExcess error and SweptToken/SweptEther events, and version bump to 7. Updated lock, prepareClaim, prepareCommitmentClaim, and refundInternal to maintain locked amount accounting.
Timestamp Variant Constructors
contracts/ERC20SwapTimestamp.sol, contracts/EtherSwapTimestamp.sol
Added explicit constructors accepting address initialOwner and forwarding to parent class constructors to enable ownership initialization.
Deployment Script
contracts/script/Deploy.sol
Updated run() to read SWAP_OWNER from environment, log the resolved owner, and pass it to EtherSwap and ERC20Swap constructors.
Test Suite
contracts/test/ERC20SwapTest.sol, contracts/test/EtherSwapTest.sol
Added comprehensive test coverage for locked amount lifecycle, sweep functionality (access control, excess detection, transfers), and ownership transfer flow. Updated constructors to pass address(this) as owner and assertions to expect version 7.
Other Test Files
contracts/test/ERC20SwapFuzzTest.sol, contracts/test/ERC20SwapTimestampTest.sol, contracts/test/EtherSwapFuzzTest.sol, contracts/test/EtherSwapTimestampTest.sol, contracts/test/RouterTestBase.sol
Updated contract instantiations to pass address(this) as owner argument to constructors.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • feat: ERC20Swap claims in Router #162: Both PRs modify ERC20Swap and EtherSwap constructors and their deployment wiring through Router/Deploy integration, creating overlapping interface changes.
  • feat: commitment swaps #137: Related through shared codepaths (prepareClaim, prepareCommitmentClaim) and new locked amount accounting that supports commitment and batch-claim functionality.
  • chore: update dependencies #140: Both PRs modify the same swap contract public APIs (VERSION constant, constructor signatures) and test coverage across ERC20Swap and EtherSwap.

Suggested reviewers

  • maybeast
  • jackstar12

Poem

🐰 Locked amounts now accounted for with care,
Excess tokens swept from the swapper's lair,
Two-step owners guard the way,
While fungible friends no longer stray!
Version seven hops along so fine,
Withdrawal functions in perfect line! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: recovery of excess funds' accurately summarizes the main objective of implementing admin withdrawal of excess tokens not backing active swaps.
Linked Issues check ✅ Passed All coding requirements from issue #177 are met: per-token accounting via lockedAmounts/lockedEther, sweep functions for admin withdrawals, proper decrement logic in all swap-ending paths, and owner-based access control.
Out of Scope Changes check ✅ Passed All changes align with issue #177 requirements. VERSION bumps to 7 and Ownable2Step inheritance enable proper contract governance while supporting the excess fund recovery feature.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch transfer-recovery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@michael1011

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
contracts/test/ERC20SwapFuzzTest.sol (1)

184-205: Consider also covering the refund path in the lifecycle test.

The test name testLockedAmountsTracksLifecycle suggests full lifecycle coverage, but only the lock→claim path is exercised. Adding a symmetric test (or a branch via a bool fuzz parameter) that verifies lockedAmounts[token] is decremented back to 0 after refund / refundCooperative would round out the accounting guarantees introduced by this PR — which is central to issue #177 (excess = balance − lockedAmounts).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/test/ERC20SwapFuzzTest.sol` around lines 184 - 205, Extend
testLockedAmountsTracksLifecycle to also exercise the refund path: add a boolean
fuzz parameter (e.g., bool useRefund) or a separate symmetric test that after
locking (using preimageHash, amount, token, claimAddress, timelock) either
executes the existing claim flow OR advances time past timelock and calls
swap.refund (or the cooperative variant swap.refundCooperative) with the same
parameters; then assert swap.lockedAmounts(address(token)) == 0 and
assertLe(swap.lockedAmounts(address(token)), token.balanceOf(address(swap))).
Use the same setup variables (preimage/preimageHash, amount, timelock, token,
claimAddress) and vm.warp as needed to move past timelock before calling refund.
contracts/EtherSwap.sol (2)

536-624: Consider wrapping the lockedEther decrements in unchecked for consistency and gas.

Same reasoning as for ERC20Swap: checkSwapIsLocked ensures the swap being claimed/refunded previously contributed amount to lockedEther, so the decrement cannot underflow. This aligns with the existing unchecked { toSend += swapAmount; } pattern used in claimBatch on the same hot paths.

♻️ Proposed diff
         delete swaps[hash];

-        // Decrement the accounted locked Ether
-        lockedEther -= amount;
+        // Decrement the accounted locked Ether.
+        // Safe: checkSwapIsLocked guarantees this swap contributed `amount` to lockedEther.
+        unchecked {
+            lockedEther -= amount;
+        }

Apply to all three decrement sites (prepareCommitmentClaim, prepareClaim, refundInternal).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/EtherSwap.sol` around lines 536 - 624, Wrap the three decrements of
lockedEther in an unchecked block to mirror the ERC20Swap optimization and save
gas: locate the three sites in prepareCommitmentClaim (if present),
prepareClaim, and refundInternal where "lockedEther -= amount;" is executed, and
change them to perform the subtraction inside an unchecked { ... } block (you
can rely on checkSwapIsLocked having validated the swap existed so underflow
cannot occur). Ensure you modify all three occurrences for consistency.

366-372: Minor: sweepToken will revert unhelpfully on token == address(0).

IERC20(address(0)).balanceOf(address(this)) triggers a low-level call to the zero address, which returns no data and causes the ABI decode to revert without a clear error. Since this is onlyOwner it's not a security issue, but a cheap explicit guard (or relying on NoExcess by short-circuiting) would give a better failure mode for mis-typed calldata.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/EtherSwap.sol` around lines 366 - 372, sweepToken currently calls
IERC20(token).balanceOf(...) which will revert confusingly when token ==
address(0); add an explicit guard at the start of sweepToken to require(token !=
address(0)) (or similarly revert with a clear error) before calling
IERC20.balanceOf, then proceed with the existing balance check using NoExcess,
emit SweptToken, and call TransferHelper.safeTransferToken; reference
sweepToken, IERC20(token).balanceOf, NoExcess, and
TransferHelper.safeTransferToken when making the change.
contracts/ERC20Swap.sol (1)

614-674: Consider wrapping the lockedAmounts decrements in unchecked for consistency and gas.

The invariant lockedAmounts[tokenAddress] >= amount at these sites is guaranteed by checkSwapIsLocked(hash) (the swap must have been locked, contributing its amount exactly once to the accumulator). This is the same reasoning used for the existing unchecked { toSend += swapAmount; } blocks in claimBatch. Using unchecked here matches that pattern and removes the per-claim/refund overflow check on a hot path.

♻️ Proposed diff
         delete swaps[hash];

-        // Decrement the accounted locked amount for this token
-        lockedAmounts[tokenAddress] -= amount;
+        // Decrement the accounted locked amount for this token.
+        // Safe: checkSwapIsLocked guarantees this swap contributed `amount` to lockedAmounts.
+        unchecked {
+            lockedAmounts[tokenAddress] -= amount;
+        }

Apply the same change to the other two decrement sites (prepareClaim and refundInternal).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/ERC20Swap.sol` around lines 614 - 674, Wrap the decrement of
lockedAmounts in an unchecked block to skip the redundant overflow check (same
pattern as the existing unchecked in claimBatch). In prepareClaim and
refundInternal replace the direct subtraction of lockedAmounts[tokenAddress] -=
amount with an unchecked block performing that subtraction (ensure the compiler
version supports unchecked). This keeps the invariant enforced by
checkSwapIsLocked(hash) while saving gas on the hot path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@contracts/ERC20Swap.sol`:
- Around line 614-674: Wrap the decrement of lockedAmounts in an unchecked block
to skip the redundant overflow check (same pattern as the existing unchecked in
claimBatch). In prepareClaim and refundInternal replace the direct subtraction
of lockedAmounts[tokenAddress] -= amount with an unchecked block performing that
subtraction (ensure the compiler version supports unchecked). This keeps the
invariant enforced by checkSwapIsLocked(hash) while saving gas on the hot path.

In `@contracts/EtherSwap.sol`:
- Around line 536-624: Wrap the three decrements of lockedEther in an unchecked
block to mirror the ERC20Swap optimization and save gas: locate the three sites
in prepareCommitmentClaim (if present), prepareClaim, and refundInternal where
"lockedEther -= amount;" is executed, and change them to perform the subtraction
inside an unchecked { ... } block (you can rely on checkSwapIsLocked having
validated the swap existed so underflow cannot occur). Ensure you modify all
three occurrences for consistency.
- Around line 366-372: sweepToken currently calls IERC20(token).balanceOf(...)
which will revert confusingly when token == address(0); add an explicit guard at
the start of sweepToken to require(token != address(0)) (or similarly revert
with a clear error) before calling IERC20.balanceOf, then proceed with the
existing balance check using NoExcess, emit SweptToken, and call
TransferHelper.safeTransferToken; reference sweepToken, IERC20(token).balanceOf,
NoExcess, and TransferHelper.safeTransferToken when making the change.

In `@contracts/test/ERC20SwapFuzzTest.sol`:
- Around line 184-205: Extend testLockedAmountsTracksLifecycle to also exercise
the refund path: add a boolean fuzz parameter (e.g., bool useRefund) or a
separate symmetric test that after locking (using preimageHash, amount, token,
claimAddress, timelock) either executes the existing claim flow OR advances time
past timelock and calls swap.refund (or the cooperative variant
swap.refundCooperative) with the same parameters; then assert
swap.lockedAmounts(address(token)) == 0 and
assertLe(swap.lockedAmounts(address(token)), token.balanceOf(address(swap))).
Use the same setup variables (preimage/preimageHash, amount, timelock, token,
claimAddress) and vm.warp as needed to move past timelock before calling refund.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98c496f8-20a5-4856-a429-1720d69ab392

📥 Commits

Reviewing files that changed from the base of the PR and between 2033fa9 and 7e5c940.

📒 Files selected for processing (12)
  • contracts/ERC20Swap.sol
  • contracts/ERC20SwapTimestamp.sol
  • contracts/EtherSwap.sol
  • contracts/EtherSwapTimestamp.sol
  • contracts/script/Deploy.sol
  • contracts/test/ERC20SwapFuzzTest.sol
  • contracts/test/ERC20SwapTest.sol
  • contracts/test/ERC20SwapTimestampTest.sol
  • contracts/test/EtherSwapFuzzTest.sol
  • contracts/test/EtherSwapTest.sol
  • contracts/test/EtherSwapTimestampTest.sol
  • contracts/test/RouterTestBase.sol

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make excess tokens withdrawable

1 participant