Skip to content

eth: Update v1 contract.#3488

Merged
JoeGruffins merged 3 commits intodecred:masterfrom
JoeGruffins:chatgptcontractv1
Feb 18, 2026
Merged

eth: Update v1 contract.#3488
JoeGruffins merged 3 commits intodecred:masterfrom
JoeGruffins:chatgptcontractv1

Conversation

@JoeGruffins
Copy link
Copy Markdown
Member

closes #3487

@JoeGruffins
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Member Author

Appears to be working.

Comment on lines +475 to +476
(bool ok,) = payable(recipient).call{value: total - fees}("");
require(ok, "AA payout failed");
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's still requiring the payment to succeed.. the last line should be removed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Now fixed in the last rebase?

@JoeGruffins
Copy link
Copy Markdown
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
Copy Markdown
Member Author

@JoeGruffins
Copy link
Copy Markdown
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
Copy Markdown
Member Author

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

@JoeGruffins
Copy link
Copy Markdown
Member Author

Fixing a problem with using just secrets https://github.com/decred/dcrdex/compare/bc991947035e85e6e4c9946f47002bfb6a0ebd23..4c78b093a52603d060c31935fc4ed4e8612959ad

problem ● This confirms there's a real issue. Let me trace through the attack scenario:

Detailed Analysis of Issue #1: Prepayment Key Collision

The ERC-4337 Execution Model

The EntryPoint processes bundles in two phases:

  1. Verification loop: Calls validateUserOp for ALL UserOps first
  2. Execution loop: Then calls the account's function (e.g., redeemAA) for ALL UserOps

The Vulnerability

The prepayKey at lines 426 and 486 is derived only from the secrets:

bytes32 prepayKey = keccak256(abi.encodePacked(secrets));

Attack Scenario:

Assume two different swaps exist that happen to use the same secret (or an attacker intentionally sets this up as initiator):
┌──────┬─────────────┬────────┬──────────┐
│ Swap │ Participant │ Secret │ Value │
├──────┼─────────────┼────────┼──────────┤
│ A │ Alice │ S │ 10 ETH │
├──────┼─────────────┼────────┼──────────┤
│ B │ Bob │ S │ 0.01 ETH │
└──────┴─────────────┴────────┴──────────┘
If both submit UserOps in the same bundle:

  1. validateUserOp(Alice's UserOp):

    • prepayKey = keccak256(S)
    • redeemPrepayments[prepayKey] = 0.001 ETH (Alice's gas fees)
    • Pays 0.001 ETH prefund to EntryPoint
  2. validateUserOp(Bob's UserOp):

    • prepayKey = keccak256(S) ← SAME KEY!
    • redeemPrepayments[prepayKey] = 0.005 ETH (Bob's gas fees) ← OVERWRITES
    • Pays 0.005 ETH prefund to EntryPoint
  3. redeemAA(Alice's UserOp):

    • fees = redeemPrepayments[prepayKey] = 0.005 ETH ← WRONG (should be 0.001)
    • delete redeemPrepayments[prepayKey]
    • Alice receives: 10 ETH - 0.005 ETH = 9.995 ETH
    • Alice LOST 0.004 ETH
  4. redeemAA(Bob's UserOp):

    • fees = redeemPrepayments[prepayKey] = 0 ← DELETED
    • Bob receives: 0.01 ETH - 0 = 0.01 ETH
    • Bob GAINED 0.005 ETH (his gas was paid by contract)

Exploitation Path

An attacker as initiator could:

  1. Create a legitimate swap with victim as participant, high value, secret S
  2. Create a malicious swap with attacker-controlled address as participant, low value, same secret S
  3. Wait for victim to submit their UserOp (secrets visible in mempool)
  4. Submit attacker's UserOp in the same bundle with higher gas fees

The victim pays the attacker's higher gas fees, and the attacker's redemption gets free gas.

Severity Reassessment: HIGH

This is exploitable when:

  • Attacker is the initiator (controls secretHash)
  • Victim uses redeemAA (account abstraction path)
  • Both UserOps land in the same bundle

@JoeGruffins
Copy link
Copy Markdown
Member Author

I think I'm at the end of my ralph loop. Going with these changes unless someone else can find something.

@JoeGruffins
Copy link
Copy Markdown
Member Author

Comment on lines +42 to +45
// This accounts for: storage reads, storage writes, ETH transfer, and loop overhead.
uint256 internal constant MIN_CALL_GAS_PER_REDEMPTION = 15000;
// Base gas for redeemAA regardless of redemption count (function overhead, final transfer).
uint256 internal constant MIN_CALL_GAS_BASE = 50000;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are these definitely big enough? We need a bit of a buffer as EVM costs may change a bit.

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

Choose a reason for hiding this comment

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

It's still requiring the payment to succeed.. the last line should be removed.

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 2 times, most recently from b492d93 to 79b7edd Compare February 9, 2026 02:54
@JoeGruffins
Copy link
Copy Markdown
Member Author

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 2 times, most recently from 371b1f3 to 79b7edd Compare February 9, 2026 03:20
@JoeGruffins
Copy link
Copy Markdown
Member Author

I thought about adding the contract tests to github cli, but I guess its not necessary since it won't change much or without a big deal.

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 2 times, most recently from f0e18c5 to 876ff96 Compare February 13, 2026 03:46
@JoeGruffins
Copy link
Copy Markdown
Member Author

@JoeGruffins
Copy link
Copy Markdown
Member Author

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 3 times, most recently from d81f292 to 06bdd17 Compare February 13, 2026 05:31
@JoeGruffins
Copy link
Copy Markdown
Member Author

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 3 times, most recently from b72c2bd to 9b6811f Compare February 16, 2026 02:09
@JoeGruffins
Copy link
Copy Markdown
Member Author

JoeGruffins commented Feb 16, 2026

https://github.com/decred/dcrdex/compare/b72c2bd6222494dfc9e438716b2b1f8510b2f6f1..9b6811f8242d2786034a08d4f8a3bc2769a55757

Zero-secret fix:

  • Added require(r.secret != bytes32(0), "zero secret") in redeem (line 305), redeemAA (line 493), and a corresponding early-return check in validateUserOp (lines 405–409). This
    prevents a redemption with bytes32(0) as the secret from resetting the swap record back to the uninitiated state.

Non-standard return codes fix:

  • Replaced the 10 custom error code constants (0–9) with just VALIDATE_SUCCESS = 0 and SIG_VALIDATION_FAILED = 1, per the ERC-4337 spec.
  • All failure paths in validateUserOp now return 1 instead of custom codes 2–9, preventing the EntryPoint from misinterpreting return values as aggregator contract addresses.
  • Updated the NatSpec to reflect the new return semantics.

I think we have to check secret hash is not the hash of secret 0 in init. I thought we were already doing this but I guess not?

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 2 times, most recently from 07ed62c to dc99b09 Compare February 16, 2026 02:58
@JoeGruffins
Copy link
Copy Markdown
Member Author

https://github.com/decred/dcrdex/compare/07ed62c51e981905b494cdbf48928608ccd5bba4..dc99b0939d3d98007b4bfbfba2f125d05cf67342

  1. Use ECDSA.tryRecover instead of ECDSA.recover (line 464)

recover reverts on malformed signatures (wrong length, empty), violating the ERC-4337 spec which expects validateUserOp to return SIG_VALIDATION_FAILED instead. Replaced with
tryRecover which returns an error code.

  1. Prevent double-validation of the same swap within a bundle (lines 87–94, 429–447)

Added a validatedAt mapping that records the block number when each swap key passes validation. A second validateUserOp call for the same swap in the same block is rejected. Entries
auto-expire in the next block. This prevents an attack where two UserOps in the same EntryPoint bundle both pass validation and both pay prefund, but only the first execution
succeeds — draining contract funds.

  1. Block unredeemable/unrefundable swaps at initiation (lines 71–74, 265–270)

Added checks in initiate to reject swaps that could never be redeemed or refunded:

  • v.initiator != address(0) — refund would burn funds to zero address
  • v.participant != address(0) — nobody can call redeem as address(0)
  • v.secretHash != bytes32(0) — no sha256 preimage produces zero
  • v.secretHash != ZERO_SECRET_HASH — only preimage is bytes32(0), which redeem rejects

Added the ZERO_SECRET_HASH constant (precomputed sha256(bytes32(0))).

@JoeGruffins
Copy link
Copy Markdown
Member Author

The vulnerabilities just go on forever.

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 3 times, most recently from 15f4c86 to db5858d Compare February 17, 2026 03:37
@JoeGruffins
Copy link
Copy Markdown
Member Author

add nonReentrant to validateUserOp and adding more test. Forgot to rebuild the go bindings.

https://github.com/decred/dcrdex/compare/15f4c860593179ca2f78adb437ca246cca38b898..db5858db0d0b14a6837dc4659ec7ced3d205c276

@JoeGruffins JoeGruffins force-pushed the chatgptcontractv1 branch 2 times, most recently from de959a5 to 65e2b0b Compare February 17, 2026 05:12
@JoeGruffins
Copy link
Copy Markdown
Member Author

38 tests covering core swap lifecycle (initiate, redeem, refund),
status/view functions, ERC-4337 account abstraction redeem via
EntryPoint.handleOps, all 9 validateUserOp error codes, and AA
security properties (reverting recipient drain protection, gas
constant adequacy, prepayment key uniqueness).
Switch v1+ contract compilation from raw solc to Hardhat, which
properly resolves npm dependency import paths (OZ v5, account-abstraction
v0.7). Extract ABI and bytecode from Hardhat artifacts for abigen.
@JoeGruffins
Copy link
Copy Markdown
Member Author

JoeGruffins commented Feb 18, 2026

Updated the ERC-4337 to 0.7 because that has better L1 calculations for base, apparently

@JoeGruffins JoeGruffins merged commit a2d2595 into decred:master Feb 18, 2026
5 checks passed
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