Skip to content

Conversation

@JoeGruffins
Copy link
Member

closes #3487

@JoeGruffins
Copy link
Member Author

JoeGruffins commented Jan 22, 2026

Current changes are straight from chatgpt. I took out the part where it "fixed" letting anyone refund. Still looking it over not tested yet. Will put comments back in.

Rethinking if maybe we do only want the initiator to refund.

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 2 times, most recently from 356f6e0 to 5d5ae86 Compare January 23, 2026 08:00
@JoeGruffins
Copy link
Member Author

Has been run through claude a few times.

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 2 times, most recently from 4cf9f65 to 730b0f9 Compare January 23, 2026 09:32
@JoeGruffins JoeGruffins marked this pull request as ready for review January 23, 2026 09:32
@JoeGruffins
Copy link
Member Author

Appears to be working.

Comment on lines +475 to +491
(bool ok,) = payable(recipient).call{value: total - fees}("");
require(ok, "AA payout failed");
Copy link
Collaborator

Choose a reason for hiding this comment

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

We have another vulnerability over here. When redeeming using account abstraction, the gas fee is paid to the entrypoint in validateUserOp, but the swap is marked as complete in redeemAA. validateUserOp should only pass if redeemAA will definitely pass, but we have two cases in which validateUserOp can pass but redeemAA will fail, causing some funds to be removed from the contract and sent to the entrypoint without the swap being marked as complete. This can then be run over and over, causing all the funds in the contract to be sent to the entrypoint.

  1. Making the participant be a smart contract that fails when funds are transfered to it. We can mitigate this by just removing the require(ok, "AA payout failed"); line.
  2. Providing a callGasLimit in the user op that is too low. We can fix this by checking the callGasLimit in validateUserOp.

Copy link
Member Author

@JoeGruffins JoeGruffins Jan 26, 2026

Choose a reason for hiding this comment

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

What keeps validateUserOp from being called by the entrypoint multiple times anyway?

Copy link
Member Author

Choose a reason for hiding this comment

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

Apparently the protocol only allows it to be called once per user action... ok

Copy link
Member Author

Choose a reason for hiding this comment

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

@JoeGruffins
Copy link
Member Author

JoeGruffins commented Jan 26, 2026

mmm. Wonder if that's it.

5 fixes by claude

● Summary of Fixed Attack Vectors

All five attacks exploit the same pattern: validateUserOp succeeds and pays prefund from the contract's pooled
funds, but redeemAA fails, leaving the swap unredeemed and repeatable.


  1. Invalid Secret Attack

Missing check: validateUserOp verified the signature but not the secret.

Attack: Submit UserOp with valid signature but wrong secret.

  • validateUserOp → passes, pays prefund
  • redeemAA → fails with "bad secret"
  • Repeat to drain funds

Fix: Added VALIDATE_INVALID_SECRET check (line 402-404)


  1. Token Type Mismatch Attack

Missing check: validateUserOp allowed any token, but redeemAA only supports ETH.

Attack: Use an ERC20 token swap for AA redemption.

  • validateUserOp → passes, pays prefund
  • redeemAA → fails with "ETH only"
  • Repeat to drain funds

Fix: Added VALIDATE_TOKEN_NOT_ETH check (line 420)


  1. Insufficient Call Gas Attack

Missing check: No validation that callGasLimit was sufficient for redeemAA execution.

Attack: Submit UserOp with artificially low callGasLimit.

  • validateUserOp → passes, pays prefund
  • redeemAA → runs out of gas, reverts
  • Repeat to drain funds

Fix: Added VALIDATE_INSUFFICIENT_CALL_GAS check (lines 431-433)


  1. Same-Block Attack

Missing check: validateUserOp checked blockNum == 0 but not blockNum < block.number.

Attack: Initiate swap and submit redemption UserOp in the same block.

  • validateUserOp → blockNum = N, N != 0, passes, pays prefund
  • redeemAA → blockNum < block.number = N < N = false, fails
  • Repeat to drain funds

Fix: Added blockNum >= block.number to the redeemability check (line 412)


  1. Empty Batch Attack

Missing check: validateUserOp didn't require reds.length > 0.

Attack: Submit UserOp with empty redemptions array (edge case, requires missingAccountFunds = 0).

  • validateUserOp → might pass with zero-value edge cases
  • redeemAA → fails with "bad batch size"

Fix: Added VALIDATE_BAD_BATCH_SIZE check (line 393)


Root Cause

The fundamental issue was that validateUserOp and redeemAA had asymmetric validation logic. Any condition that
passes validation but fails execution allows repeated prefund drainage from the contract's pooled swap funds.

@JoeGruffins
Copy link
Member Author

@JoeGruffins
Copy link
Member Author

Another interesting attack vector but would have to be the same user attacking themselves so maybe ok? or we can change:

self attack C-02 Prepayment

The ERC-4337 Flow

In account abstraction, the EntryPoint processes UserOps in two phases:

  1. Validation phase: All validateUserOp() calls run first
  2. Execution phase: All actual calls (redeemAA()) run after

How Prepayments Work in This Contract

In validateUserOp (lines 416-422):
if (i == 0) {
participant = r.v.participant;
token = r.token;
if (token != address(0)) return VALIDATE_TOKEN_NOT_ETH;
redeemPrepayments[contractKey(r.token, r.v)] = missingAccountFunds; // ← stored by first swap's key
}

In redeemAA (lines 491-497):
bytes32 payKey = contractKey(redemptions[0].token, redemptions[0].v);
uint256 fees = redeemPrepayments[payKey]; // ← retrieved by first swap's key
delete redeemPrepayments[payKey];
(bool ok,) = payable(recipient).call{value: total - fees}("");

The Bug Scenario

Two UserOps in the same bundle, both with swap A as their first redemption:
┌─────────────────────┬──────────┬──────────┐
│ │ UserOp1 │ UserOp2 │
├─────────────────────┼──────────┼──────────┤
│ Redemptions │ [A, B] │ [A, C] │
├─────────────────────┼──────────┼──────────┤
│ missingAccountFunds │ 0.01 ETH │ 0.05 ETH │
└─────────────────────┴──────────┴──────────┘
What happens:

  1. validateUserOp(UserOp1):
    - Swap A exists and is unredeemed ✓
    - Stores redeemPrepayments[keyA] = 0.01 ETH
    - Pays 0.01 ETH to EntryPoint
    - Returns success
  2. validateUserOp(UserOp2):
    - Swap A still exists and unredeemed (no execution yet!) ✓
    - Overwrites redeemPrepayments[keyA] = 0.05 ETH
    - Pays 0.05 ETH to EntryPoint
    - Returns success
  3. redeemAA(UserOp1) executes:
    - Redeems A and B successfully
    - Reads fees = redeemPrepayments[keyA] → gets 0.05 ETH (wrong!)
    - Deletes the entry
    - Pays recipient total - 0.05 ETH instead of total - 0.01 ETH
    - Recipient loses 0.04 ETH
  4. redeemAA(UserOp2) executes:
    - Tries to redeem A → reverts "already redeemed"
    - UserOp2's 0.05 ETH prefund is consumed by EntryPoint for gas

Impact Assessment

Who can trigger this?

  • Both UserOps must have the same participant for swap A (signature verification)
  • So it's the same person submitting both
  • Requires a bundler to include both conflicting UserOps

Exploitability: Low

  • An attacker would be attacking themselves
  • A malicious bundler could grief users, but bundlers are typically trusted
  • A buggy client could accidentally do this

Severity: Medium (not Critical as ChatGPT claimed)

  • It's a correctness bug, not easily exploitable
  • Users could lose funds in edge cases

@JoeGruffins
Copy link
Member Author

Should fix the last issue and probably a better scheme than the initial changes. https://github.com/decred/dcrdex/compare/5f102b003b978664543f47478938ca7a28c14509..bc991947035e85e6e4c9946f47002bfb6a0ebd23

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.

eth: Claude and chatgpt v1 contract review.

2 participants