Skip to content

Commit 687d9e1

Browse files
ConjunctiveNormalFormalanhwucodybornclaudesemgrep-code-uniswap[bot]
authored
fix(DCA): dca allocation bug (#360)
* fix exclusivity edge-case * add tests * full test snapshot * add exclusivityEndBlock to cosigner data * forge fmt * fuzz testing exclusivity * fix(DCA): allocation bug (#358) * dca allocation fix * new test * unuseful comment * remove recipients check * forge fmt * view -> pure * test: exactIn sweep rounding remainder * forge fmt * revert: undo last two commits * docs: comments for clarity around fix code --------- Co-authored-by: Alan Wu <alanwu100@gmail.com> * ci: integrate Nethermind Audit Agent for automated security scanning (#357) * ci: integrate Nethermind Audit Agent for automated security scanning Add GitHub Actions workflow to automatically scan pull requests using Nethermind's Audit Agent. The workflow triggers on PRs to main and performs quick security scans on v4 contract files, providing early vulnerability detection in the development process. Resolves: PROTO-1020 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Update .github/workflows/audit-agent.yml Co-authored-by: semgrep-code-uniswap[bot] <212759065+semgrep-code-uniswap[bot]@users.noreply.github.com> * Update .github/workflows/audit-agent.yml Co-authored-by: semgrep-code-uniswap[bot] <212759065+semgrep-code-uniswap[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: semgrep-code-uniswap[bot] <212759065+semgrep-code-uniswap[bot]@users.noreply.github.com> * ci: integrate Nethermind Audit Agent for automated security scanning (#357) * ci: integrate Nethermind Audit Agent for automated security scanning Add GitHub Actions workflow to automatically scan pull requests using Nethermind's Audit Agent. The workflow triggers on PRs to main and performs quick security scans on v4 contract files, providing early vulnerability detection in the development process. Resolves: PROTO-1020 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Update .github/workflows/audit-agent.yml Co-authored-by: semgrep-code-uniswap[bot] <212759065+semgrep-code-uniswap[bot]@users.noreply.github.com> * Update .github/workflows/audit-agent.yml Co-authored-by: semgrep-code-uniswap[bot] <212759065+semgrep-code-uniswap[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: semgrep-code-uniswap[bot] <212759065+semgrep-code-uniswap[bot]@users.noreply.github.com> * fix(v4 reactor): exclusivity edge case (#356) * fix exclusivity edge-case * add tests * full test snapshot * add exclusivityEndBlock to cosigner data * forge fmt * fuzz testing exclusivity --------- Co-authored-by: Alan Wu <alanwu100@gmail.com> Co-authored-by: Cody Born <cody.born@uniswap.org> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: semgrep-code-uniswap[bot] <212759065+semgrep-code-uniswap[bot]@users.noreply.github.com>
1 parent e5aa9c4 commit 687d9e1

4 files changed

Lines changed: 135 additions & 11 deletions

File tree

src/v4/hooks/dca/DCAHook.sol

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -393,32 +393,57 @@ contract DCAHook is IPreExecutionHook, IDCAHook {
393393
DCAOrderCosignerData memory cosignerData,
394394
OutputToken[] memory outputs
395395
) internal pure {
396-
// Aggregate outputs per recipient and compute totalOutput
396+
// Aggregate outputs and compute totalOutput
397397
uint256 totalOutput = 0;
398-
// Use a temporary in-memory structure to tally by recipient (no memory mapping in Solidity):
399-
// Approach: loop once to total output; for each allocation, loop outputs to sum matching recipient.
400398
uint256 outputsLength = outputs.length;
401399
for (uint256 i = 0; i < outputsLength; i++) {
402400
// token already checked equals intent.outputToken in _beforeTokenTransfer
403401
totalOutput += outputs[i].amount;
404402
}
405403

406404
uint256 allocationsLength = intent.outputAllocations.length;
405+
uint256[] memory expected = new uint256[](allocationsLength);
406+
uint256 sumExpected = 0;
407+
408+
// Select a deterministic recipient to receive any rounding remainder for EXACT_OUT.
409+
// We choose the allocation with the highest bps; ties pick the first max due to strict `>`.
410+
uint256 maxBps = 0;
411+
uint256 maxBpsIndex = 0;
412+
413+
for (uint256 i = 0; i < allocationsLength; i++) {
414+
uint256 bps = uint256(intent.outputAllocations[i].basisPoints);
415+
if (bps > maxBps) {
416+
maxBps = bps;
417+
maxBpsIndex = i;
418+
}
419+
// Floor(totalOutput * bps / BPS). Sum of floors may be < totalOutput.
420+
expected[i] = Math.mulDiv(totalOutput, bps, BPS);
421+
sumExpected += expected[i];
422+
}
423+
424+
if (!intent.isExactIn) {
425+
// EXACT_OUT requires expected[] to sum exactly to totalOutput; otherwise allocation checks can be unfillable.
426+
uint256 remainder = totalOutput - sumExpected;
427+
if (remainder > 0) {
428+
expected[maxBpsIndex] += remainder;
429+
}
430+
}
431+
407432
for (uint256 i = 0; i < allocationsLength; i++) {
408433
address rcpt = intent.outputAllocations[i].recipient;
409-
uint256 expected = Math.mulDiv(totalOutput, uint256(intent.outputAllocations[i].basisPoints), BPS);
410434
uint256 actual = 0;
411435
for (uint256 j = 0; j < outputsLength; j++) {
412436
if (outputs[j].recipient == rcpt) actual += outputs[j].amount;
413437
}
414438
if (intent.isExactIn) {
415439
// Allow ±1 wei for integer division rounding
416-
if (!(actual + 1 >= expected && actual <= expected + 1)) {
417-
revert AllocationMismatch(rcpt, actual, expected);
440+
uint256 exp = expected[i];
441+
if (!(actual + 1 >= exp && actual <= exp + 1)) {
442+
revert AllocationMismatch(rcpt, actual, exp);
418443
}
419444
} else {
420-
if (actual != expected) {
421-
revert AllocationMismatch(rcpt, actual, expected);
445+
if (actual != expected[i]) {
446+
revert AllocationMismatch(rcpt, actual, expected[i]);
422447
}
423448
}
424449
}

src/v4/lib/ExclusivityLib.sol

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ library ExclusivityLib {
4848
uint256 exclusivityOverrideBps,
4949
uint256 blockNumberish,
5050
address filler
51-
) internal view {
51+
) internal pure {
5252
_handleExclusiveOverride(order, exclusive, exclusivityEnd, exclusivityOverrideBps, blockNumberish, filler);
5353
}
5454

@@ -66,7 +66,7 @@ library ExclusivityLib {
6666
uint256 exclusivityOverrideBps,
6767
uint256 currentPosition,
6868
address filler
69-
) internal view {
69+
) internal pure {
7070
// if the filler has fill right, we proceed with the order as-is
7171
if (hasFillingRights(exclusive, exclusivityEnd, currentPosition, filler)) {
7272
return;
@@ -99,7 +99,7 @@ library ExclusivityLib {
9999
/// @dev if the order has active exclusivity and the current filler is not the exclusive address, returns false
100100
function hasFillingRights(address exclusive, uint256 exclusivityEnd, uint256 currentPosition, address filler)
101101
internal
102-
view
102+
pure
103103
returns (bool)
104104
{
105105
return exclusive == address(0) || currentPosition > exclusivityEnd || exclusive == filler;

test/v4/hooks/dca/DCAHookHarness.sol

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
PermitData
1313
} from "../../../../src/v4/hooks/dca/DCAStructs.sol";
1414
import {ResolvedOrder} from "../../../../src/v4/base/ReactorStructs.sol";
15+
import {OutputToken} from "../../../../src/base/ReactorStructs.sol";
1516
import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
1617
import {IReactor} from "../../../../src/v4/interfaces/IReactor.sol";
1718

@@ -67,6 +68,15 @@ contract DCAHookHarness is DCAHook {
6768
_validateChunkSize(intent, cosignerData, inputAmount);
6869
}
6970

71+
/// @notice Exposes the internal _validateOutputDistribution function for testing
72+
function validateOutputDistribution(
73+
DCAIntent memory intent,
74+
DCAOrderCosignerData memory cosignerData,
75+
OutputToken[] memory outputs
76+
) external pure {
77+
_validateOutputDistribution(intent, cosignerData, outputs);
78+
}
79+
7080
/// @notice Helper to create a basic DCA intent for testing
7181
function createTestIntent(address swapper, uint96 nonce, bool isExactIn, uint256 minChunk, uint256 maxChunk)
7282
external
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-License-Identifier: GPL-2.0-or-later
2+
pragma solidity ^0.8.0;
3+
4+
import {Test} from "forge-std/Test.sol";
5+
import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
6+
import {DeployPermit2} from "../../../util/DeployPermit2.sol";
7+
import {DCAHookHarness} from "./DCAHookHarness.sol";
8+
import {IReactor} from "../../../../src/v4/interfaces/IReactor.sol";
9+
import {
10+
DCAIntent,
11+
DCAOrderCosignerData,
12+
OutputAllocation,
13+
PrivateIntent,
14+
FeedInfo
15+
} from "../../../../src/v4/hooks/dca/DCAStructs.sol";
16+
import {OutputToken} from "../../../../src/base/ReactorStructs.sol";
17+
import {IDCAHook} from "../../../../src/v4/interfaces/IDCAHook.sol";
18+
19+
contract DCAHook_validateOutputDistributionTest is Test, DeployPermit2 {
20+
DCAHookHarness hook;
21+
IPermit2 permit2;
22+
address constant REACTOR_ADDRESS = address(0x2345);
23+
IReactor constant REACTOR = IReactor(REACTOR_ADDRESS);
24+
25+
address constant SWAPPER = address(0x1234);
26+
uint96 constant NONCE = 42;
27+
address constant COSIGNER = address(0x5678);
28+
address constant RECIPIENT_A = address(0xAAAA);
29+
address constant RECIPIENT_B = address(0xBBBB);
30+
31+
function setUp() public {
32+
permit2 = IPermit2(deployPermit2());
33+
hook = new DCAHookHarness(permit2, REACTOR);
34+
}
35+
36+
function _createExactOutIntent() internal view returns (DCAIntent memory) {
37+
OutputAllocation[] memory allocations = new OutputAllocation[](2);
38+
allocations[0] = OutputAllocation({recipient: RECIPIENT_A, basisPoints: 5000});
39+
allocations[1] = OutputAllocation({recipient: RECIPIENT_B, basisPoints: 5000});
40+
41+
PrivateIntent memory privateIntent = PrivateIntent({
42+
totalAmount: 0, exactFrequency: 0, numChunks: 0, salt: bytes32(0), oracleFeeds: new FeedInfo[](0)
43+
});
44+
45+
return DCAIntent({
46+
swapper: SWAPPER,
47+
nonce: NONCE,
48+
chainId: block.chainid,
49+
hookAddress: address(hook),
50+
isExactIn: false,
51+
inputToken: address(0x1111),
52+
outputToken: address(0x2222),
53+
cosigner: COSIGNER,
54+
minPeriod: 0,
55+
maxPeriod: 0,
56+
minChunkSize: 1,
57+
maxChunkSize: 10_000,
58+
minPrice: 0,
59+
deadline: block.timestamp + 1 days,
60+
outputAllocations: allocations,
61+
privateIntent: privateIntent
62+
});
63+
}
64+
65+
function test_validateOutputDistribution_exactOut_remainderAssignedToFirstMaxBpsRecipient() public {
66+
DCAIntent memory intent = _createExactOutIntent();
67+
DCAOrderCosignerData memory cosignerData = hook.createTestCosignerData(SWAPPER, NONCE, 101, 0, 0);
68+
69+
// 50/50 split with odd total: remainder should go to first max-bps recipient (RECIPIENT_A).
70+
OutputToken[] memory outputs = new OutputToken[](2);
71+
outputs[0] = OutputToken({token: intent.outputToken, amount: 51, recipient: RECIPIENT_A});
72+
outputs[1] = OutputToken({token: intent.outputToken, amount: 50, recipient: RECIPIENT_B});
73+
74+
hook.validateOutputDistribution(intent, cosignerData, outputs);
75+
}
76+
77+
function test_validateOutputDistribution_exactOut_remainderOnOtherRecipient_reverts() public {
78+
DCAIntent memory intent = _createExactOutIntent();
79+
DCAOrderCosignerData memory cosignerData = hook.createTestCosignerData(SWAPPER, NONCE, 101, 0, 0);
80+
81+
// Remainder incorrectly assigned to RECIPIENT_B should revert.
82+
OutputToken[] memory outputs = new OutputToken[](2);
83+
outputs[0] = OutputToken({token: intent.outputToken, amount: 50, recipient: RECIPIENT_A});
84+
outputs[1] = OutputToken({token: intent.outputToken, amount: 51, recipient: RECIPIENT_B});
85+
86+
vm.expectRevert(abi.encodeWithSelector(IDCAHook.AllocationMismatch.selector, RECIPIENT_A, 50, 51));
87+
hook.validateOutputDistribution(intent, cosignerData, outputs);
88+
}
89+
}

0 commit comments

Comments
 (0)