@bananapus/router-terminal-v6 0.0.31 → 0.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RISKS.md +1 -1
- package/package.json +4 -4
- package/src/JBPayRouteResolver.sol +16 -2
- package/src/JBRouterTerminal.sol +1 -1
- package/test/RouterTerminal.t.sol +1 -0
- package/test/audit/DeployBuybackHookZero.t.sol +3 -1
- package/test/codex/RegistryForwardingLossyToken.t.sol +229 -0
- package/test/fork/RouterTerminalFOTFork.t.sol +363 -0
package/RISKS.md
CHANGED
|
@@ -17,7 +17,7 @@ Pool discovery ranks candidates by instantaneous liquidity, so an attacker could
|
|
|
17
17
|
When a user provides `quoteForSwap` metadata, the quote may not match the auto-selected output token. Frontends should set `quoteForSwap` per the expected output token.
|
|
18
18
|
|
|
19
19
|
**Multi-hop cashout slippage cleared after first hop.** *(Minor)*
|
|
20
|
-
Only the final output matters; the outer function enforces end-to-end minimum via `minReclaimed`. Intermediate per-hop slippage checks are intentionally omitted.
|
|
20
|
+
Only the final output matters; the outer function enforces end-to-end minimum via `minReclaimed`. Intermediate per-hop slippage checks are intentionally omitted. Maximum 20 recursive cashout iterations allowed (`_MAX_CASHOUT_ITERATIONS`); beyond that the operation reverts.
|
|
21
21
|
|
|
22
22
|
**Zero oracle quote disables swap protection.** *(Minor)*
|
|
23
23
|
When the oracle returns zero (no liquidity), slippage tolerance becomes zero. The swap would fail anyway due to lack of liquidity, so this has no practical impact.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/router-terminal-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-router-terminal-v6'"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@bananapus/buyback-hook-v6": "^0.0.
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
22
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
20
|
+
"@bananapus/buyback-hook-v6": "^0.0.30",
|
|
21
|
+
"@bananapus/core-v6": "^0.0.36",
|
|
22
|
+
"@bananapus/permission-ids-v6": "^0.0.19",
|
|
23
23
|
"@openzeppelin/contracts": "^5.6.1",
|
|
24
24
|
"@uniswap/permit2": "github:Uniswap/permit2",
|
|
25
25
|
"@uniswap/v3-core": "github:Uniswap/v3-core#0.8",
|
|
@@ -274,9 +274,23 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
// Decode only the minimum token-count commitments needed to score the buyback-enhanced preview.
|
|
277
|
-
(
|
|
277
|
+
(,,,,,,,,,, uint256 minimumBeneficiaryTokenCount, uint256 minimumReservedTokenCount,) = abi.decode(
|
|
278
278
|
specification.metadata,
|
|
279
|
-
(
|
|
279
|
+
(
|
|
280
|
+
bool,
|
|
281
|
+
uint256,
|
|
282
|
+
uint256,
|
|
283
|
+
bool,
|
|
284
|
+
address,
|
|
285
|
+
uint256,
|
|
286
|
+
uint256,
|
|
287
|
+
int24,
|
|
288
|
+
uint128,
|
|
289
|
+
bytes32,
|
|
290
|
+
uint256,
|
|
291
|
+
uint256,
|
|
292
|
+
uint256
|
|
293
|
+
)
|
|
280
294
|
);
|
|
281
295
|
|
|
282
296
|
// Keep whichever decoded hook commitment implies the stronger user-visible preview outcome.
|
package/src/JBRouterTerminal.sol
CHANGED
|
@@ -2331,7 +2331,7 @@ contract JBRouterTerminal is
|
|
|
2331
2331
|
if (address(key.hooks) != address(0)) {
|
|
2332
2332
|
// Build the two-element lookback array: [_TWAP_WINDOW seconds ago, now].
|
|
2333
2333
|
uint32[] memory secondsAgos = new uint32[](2);
|
|
2334
|
-
secondsAgos[0] = _TWAP_WINDOW; // Start of the window (
|
|
2334
|
+
secondsAgos[0] = _TWAP_WINDOW; // Start of the window (120 seconds ago).
|
|
2335
2335
|
secondsAgos[1] = 0; // End of the window (current block).
|
|
2336
2336
|
|
|
2337
2337
|
// Ask the hook for cumulative tick data over the window. Silently catch if it doesn't support it.
|
|
@@ -286,7 +286,8 @@ contract DeployBuybackHookZeroTest is Test {
|
|
|
286
286
|
reclaimToken = new AuditMockERC20();
|
|
287
287
|
nativeTerminal = new AuditMockPreviewDestTerminal(JBConstants.NATIVE_TOKEN, 100);
|
|
288
288
|
tokenBTerminal = new AuditMockPreviewDestTerminal(address(reclaimToken), 150);
|
|
289
|
-
|
|
289
|
+
vm.deal(address(this), 80);
|
|
290
|
+
nativeCashOut = new AuditMockCashOutTerminal{value: 80}(jbToken, JBConstants.NATIVE_TOKEN, 40, 40);
|
|
290
291
|
tokenBCashOut = new AuditMockCashOutTerminal(jbToken, address(reclaimToken), 50, 50);
|
|
291
292
|
|
|
292
293
|
reclaimToken.mint(address(tokenBCashOut), 50);
|
|
@@ -393,6 +394,7 @@ contract DeployBuybackHookZeroTest is Test {
|
|
|
393
394
|
false,
|
|
394
395
|
address(0),
|
|
395
396
|
uint256(0),
|
|
397
|
+
uint256(0),
|
|
396
398
|
int24(0),
|
|
397
399
|
uint128(0),
|
|
398
400
|
PoolId.wrap(bytes32(0)),
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
7
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
8
|
+
import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
|
|
9
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
10
|
+
import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
|
|
11
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
12
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
13
|
+
import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
|
|
14
|
+
import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
|
|
15
|
+
import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
|
|
16
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
17
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
18
|
+
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
19
|
+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
|
|
20
|
+
|
|
21
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
22
|
+
import {JBRouterTerminalRegistry} from "../../src/JBRouterTerminalRegistry.sol";
|
|
23
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
24
|
+
|
|
25
|
+
contract LossyToken {
|
|
26
|
+
uint256 public constant BPS = 10_000;
|
|
27
|
+
uint256 public constant FEE_BPS = 1000;
|
|
28
|
+
|
|
29
|
+
mapping(address => uint256) public balanceOf;
|
|
30
|
+
mapping(address => mapping(address => uint256)) public allowance;
|
|
31
|
+
|
|
32
|
+
function mint(address to, uint256 amount) external {
|
|
33
|
+
balanceOf[to] += amount;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function approve(address spender, uint256 amount) external returns (bool) {
|
|
37
|
+
allowance[msg.sender][spender] = amount;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function transfer(address to, uint256 amount) external returns (bool) {
|
|
42
|
+
_transfer(msg.sender, to, amount);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
|
|
47
|
+
uint256 allowed = allowance[from][msg.sender];
|
|
48
|
+
if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount;
|
|
49
|
+
_transfer(from, to, amount);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _transfer(address from, address to, uint256 amount) internal {
|
|
54
|
+
uint256 fee = amount * FEE_BPS / BPS;
|
|
55
|
+
uint256 received = amount - fee;
|
|
56
|
+
balanceOf[from] -= amount;
|
|
57
|
+
balanceOf[to] += received;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
contract LossyFinalTerminal {
|
|
62
|
+
uint256 public lastNominalAmount;
|
|
63
|
+
uint256 public lastActualReceipt;
|
|
64
|
+
|
|
65
|
+
function pay(
|
|
66
|
+
uint256,
|
|
67
|
+
address token,
|
|
68
|
+
uint256 amount,
|
|
69
|
+
address,
|
|
70
|
+
uint256,
|
|
71
|
+
string calldata,
|
|
72
|
+
bytes calldata
|
|
73
|
+
)
|
|
74
|
+
external
|
|
75
|
+
returns (uint256)
|
|
76
|
+
{
|
|
77
|
+
lastNominalAmount = amount;
|
|
78
|
+
uint256 beforeBalance = LossyToken(token).balanceOf(address(this));
|
|
79
|
+
require(LossyToken(token).transferFrom(msg.sender, address(this), amount), "transferFrom failed");
|
|
80
|
+
lastActualReceipt = LossyToken(token).balanceOf(address(this)) - beforeBalance;
|
|
81
|
+
return lastActualReceipt;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function previewPayFor(
|
|
85
|
+
uint256,
|
|
86
|
+
address,
|
|
87
|
+
uint256 amount,
|
|
88
|
+
address,
|
|
89
|
+
bytes calldata
|
|
90
|
+
)
|
|
91
|
+
external
|
|
92
|
+
pure
|
|
93
|
+
returns (JBRuleset memory ruleset, uint256, uint256, JBPayHookSpecification[] memory hookSpecifications)
|
|
94
|
+
{
|
|
95
|
+
ruleset = JBRuleset({
|
|
96
|
+
cycleNumber: 1,
|
|
97
|
+
id: 1,
|
|
98
|
+
basedOnId: 0,
|
|
99
|
+
start: 0,
|
|
100
|
+
duration: 0,
|
|
101
|
+
weight: 0,
|
|
102
|
+
weightCutPercent: 0,
|
|
103
|
+
approvalHook: IJBRulesetApprovalHook(address(0)),
|
|
104
|
+
metadata: 0
|
|
105
|
+
});
|
|
106
|
+
hookSpecifications = new JBPayHookSpecification[](0);
|
|
107
|
+
return (ruleset, amount, 0, hookSpecifications);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function accountingContextsOf(uint256) external pure returns (JBAccountingContext[] memory contexts) {
|
|
111
|
+
contexts = new JBAccountingContext[](0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function accountingContextForTokenOf(
|
|
115
|
+
uint256,
|
|
116
|
+
address token
|
|
117
|
+
)
|
|
118
|
+
external
|
|
119
|
+
pure
|
|
120
|
+
returns (JBAccountingContext memory context)
|
|
121
|
+
{
|
|
122
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
123
|
+
context = JBAccountingContext({token: token, decimals: 18, currency: uint32(uint160(token))});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function supportsInterface(bytes4) external pure returns (bool) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
contract RegistryForwardingLossyTokenTest is Test {
|
|
132
|
+
uint256 internal constant PROJECT_ID = 1;
|
|
133
|
+
uint256 internal constant AMOUNT = 100e18;
|
|
134
|
+
|
|
135
|
+
JBRouterTerminal internal router;
|
|
136
|
+
JBRouterTerminalRegistry internal registry;
|
|
137
|
+
|
|
138
|
+
IJBDirectory internal directory;
|
|
139
|
+
IJBPermissions internal permissions;
|
|
140
|
+
IJBProjects internal projects;
|
|
141
|
+
IJBTokens internal tokens;
|
|
142
|
+
IPermit2 internal permit2;
|
|
143
|
+
IWETH9 internal weth;
|
|
144
|
+
IUniswapV3Factory internal factory;
|
|
145
|
+
IPoolManager internal poolManager;
|
|
146
|
+
|
|
147
|
+
LossyToken internal token;
|
|
148
|
+
LossyFinalTerminal internal finalTerminal;
|
|
149
|
+
|
|
150
|
+
address internal owner = makeAddr("owner");
|
|
151
|
+
address internal payer = makeAddr("payer");
|
|
152
|
+
|
|
153
|
+
function setUp() public {
|
|
154
|
+
directory = IJBDirectory(makeAddr("directory"));
|
|
155
|
+
permissions = IJBPermissions(makeAddr("permissions"));
|
|
156
|
+
projects = IJBProjects(makeAddr("projects"));
|
|
157
|
+
tokens = IJBTokens(makeAddr("tokens"));
|
|
158
|
+
permit2 = IPermit2(makeAddr("permit2"));
|
|
159
|
+
weth = IWETH9(makeAddr("weth"));
|
|
160
|
+
factory = IUniswapV3Factory(makeAddr("factory"));
|
|
161
|
+
poolManager = IPoolManager(makeAddr("poolManager"));
|
|
162
|
+
|
|
163
|
+
vm.etch(address(directory), hex"00");
|
|
164
|
+
vm.etch(address(permissions), hex"00");
|
|
165
|
+
vm.etch(address(projects), hex"00");
|
|
166
|
+
vm.etch(address(tokens), hex"00");
|
|
167
|
+
vm.etch(address(permit2), hex"00");
|
|
168
|
+
vm.etch(address(weth), hex"00");
|
|
169
|
+
vm.etch(address(factory), hex"00");
|
|
170
|
+
vm.etch(address(poolManager), hex"00");
|
|
171
|
+
|
|
172
|
+
router = new JBRouterTerminal({
|
|
173
|
+
directory: directory,
|
|
174
|
+
tokens: tokens,
|
|
175
|
+
permit2: permit2,
|
|
176
|
+
weth: weth,
|
|
177
|
+
factory: factory,
|
|
178
|
+
poolManager: poolManager,
|
|
179
|
+
buybackHook: address(0),
|
|
180
|
+
univ4Hook: address(0),
|
|
181
|
+
trustedForwarder: address(0)
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
registry = new JBRouterTerminalRegistry({
|
|
185
|
+
permissions: permissions, projects: projects, permit2: permit2, owner: owner, trustedForwarder: address(0)
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
token = new LossyToken();
|
|
189
|
+
finalTerminal = new LossyFinalTerminal();
|
|
190
|
+
|
|
191
|
+
vm.prank(owner);
|
|
192
|
+
registry.setDefaultTerminal(IJBTerminal(address(finalTerminal)));
|
|
193
|
+
|
|
194
|
+
token.mint(payer, AMOUNT);
|
|
195
|
+
vm.prank(payer);
|
|
196
|
+
token.approve(address(router), type(uint256).max);
|
|
197
|
+
|
|
198
|
+
vm.mockCall(
|
|
199
|
+
address(tokens), abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(address(token)))), abi.encode(uint256(0))
|
|
200
|
+
);
|
|
201
|
+
vm.mockCall(
|
|
202
|
+
address(directory),
|
|
203
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(token))),
|
|
204
|
+
abi.encode(address(registry))
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function test_routerSkipsFinalReceiptCheckWhenRegistryForwardsLossyToken() public {
|
|
209
|
+
vm.prank(payer);
|
|
210
|
+
uint256 minted = router.pay(PROJECT_ID, address(token), AMOUNT, payer, 0, "", "");
|
|
211
|
+
|
|
212
|
+
assertEq(token.balanceOf(address(registry)), 0, "registry should not retain leftovers");
|
|
213
|
+
assertEq(finalTerminal.lastNominalAmount(), 81e18, "registry forwards only what it received");
|
|
214
|
+
assertEq(finalTerminal.lastActualReceipt(), 72.9e18, "final terminal receives less on the second lossy hop");
|
|
215
|
+
assertEq(minted, 72.9e18, "call succeeds using the shrunken receipt");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function test_directRouterPathRevertsOnSameLossyToken() public {
|
|
219
|
+
vm.mockCall(
|
|
220
|
+
address(directory),
|
|
221
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(token))),
|
|
222
|
+
abi.encode(address(finalTerminal))
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
vm.prank(payer);
|
|
226
|
+
vm.expectRevert(JBRouterTerminal.JBRouterTerminal_NonStandardTerminalToken.selector);
|
|
227
|
+
router.pay(PROJECT_ID, address(token), AMOUNT, payer, 0, "", "");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
// JB core (deployed fresh within fork).
|
|
7
|
+
import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
|
|
8
|
+
import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
|
|
9
|
+
import {JBDirectory} from "@bananapus/core-v6/src/JBDirectory.sol";
|
|
10
|
+
import {JBRulesets} from "@bananapus/core-v6/src/JBRulesets.sol";
|
|
11
|
+
import {JBTokens} from "@bananapus/core-v6/src/JBTokens.sol";
|
|
12
|
+
import {JBERC20} from "@bananapus/core-v6/src/JBERC20.sol";
|
|
13
|
+
import {JBSplits} from "@bananapus/core-v6/src/JBSplits.sol";
|
|
14
|
+
import {JBPrices} from "@bananapus/core-v6/src/JBPrices.sol";
|
|
15
|
+
import {JBController} from "@bananapus/core-v6/src/JBController.sol";
|
|
16
|
+
import {JBFundAccessLimits} from "@bananapus/core-v6/src/JBFundAccessLimits.sol";
|
|
17
|
+
import {JBFeelessAddresses} from "@bananapus/core-v6/src/JBFeelessAddresses.sol";
|
|
18
|
+
import {JBTerminalStore} from "@bananapus/core-v6/src/JBTerminalStore.sol";
|
|
19
|
+
import {JBMultiTerminal} from "@bananapus/core-v6/src/JBMultiTerminal.sol";
|
|
20
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
21
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
22
|
+
import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
|
|
23
|
+
import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
|
|
24
|
+
import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
|
|
25
|
+
import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
|
|
26
|
+
import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
|
|
27
|
+
import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
|
|
28
|
+
|
|
29
|
+
// Uniswap.
|
|
30
|
+
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
31
|
+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
|
|
32
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
33
|
+
|
|
34
|
+
// OpenZeppelin.
|
|
35
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
36
|
+
|
|
37
|
+
// Router terminal.
|
|
38
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
39
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
40
|
+
|
|
41
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Mock: Fee-on-transfer ERC20 (burns a percentage per transfer).
|
|
43
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
contract MockFoTToken {
|
|
46
|
+
string public name = "FeeOnTransfer";
|
|
47
|
+
string public symbol = "FOT";
|
|
48
|
+
uint8 public decimals = 18;
|
|
49
|
+
uint256 public totalSupply;
|
|
50
|
+
|
|
51
|
+
mapping(address => uint256) public balanceOf;
|
|
52
|
+
mapping(address => mapping(address => uint256)) public allowance;
|
|
53
|
+
|
|
54
|
+
/// @notice Fee deducted per transfer (in basis points, e.g. 100 = 1%).
|
|
55
|
+
uint256 public immutable feePercent;
|
|
56
|
+
|
|
57
|
+
constructor(uint256 _feePercent) {
|
|
58
|
+
feePercent = _feePercent;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mint(address to, uint256 amount) external {
|
|
62
|
+
balanceOf[to] += amount;
|
|
63
|
+
totalSupply += amount;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function approve(address spender, uint256 amount) external returns (bool) {
|
|
67
|
+
allowance[msg.sender][spender] = amount;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function transfer(address to, uint256 amount) external returns (bool) {
|
|
72
|
+
balanceOf[msg.sender] -= amount;
|
|
73
|
+
uint256 fee = (amount * feePercent) / 10_000;
|
|
74
|
+
uint256 received = amount - fee;
|
|
75
|
+
balanceOf[to] += received;
|
|
76
|
+
totalSupply -= fee; // fee is burned
|
|
77
|
+
emit Transfer(msg.sender, to, received);
|
|
78
|
+
if (fee > 0) emit Transfer(msg.sender, address(0), fee);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
|
|
83
|
+
allowance[from][msg.sender] -= amount;
|
|
84
|
+
balanceOf[from] -= amount;
|
|
85
|
+
uint256 fee = (amount * feePercent) / 10_000;
|
|
86
|
+
uint256 received = amount - fee;
|
|
87
|
+
balanceOf[to] += received;
|
|
88
|
+
totalSupply -= fee; // fee is burned
|
|
89
|
+
emit Transfer(from, to, received);
|
|
90
|
+
if (fee > 0) emit Transfer(from, address(0), fee);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
event Transfer(address indexed from, address indexed to, uint256 value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// @notice Fork test: fee-on-transfer token through the router terminal.
|
|
98
|
+
/// @dev Verifies that the receipt enforcement correctly detects and rejects FOT tokens,
|
|
99
|
+
/// preventing accounting mismatches between the router and the destination terminal.
|
|
100
|
+
///
|
|
101
|
+
/// The router's `_acceptFundsFor` uses balance-delta to capture the actual received amount
|
|
102
|
+
/// (handles the first transfer fee). However, the second transfer (router → terminal) also
|
|
103
|
+
/// incurs a fee, causing `_enforceStandardTerminalReceipt` to revert with
|
|
104
|
+
/// `JBRouterTerminal_NonStandardTerminalToken`.
|
|
105
|
+
///
|
|
106
|
+
/// This is the correct defensive behavior — FOT tokens are not supported by design.
|
|
107
|
+
contract RouterTerminalFOTForkTest is Test {
|
|
108
|
+
uint256 constant BLOCK_NUMBER = 21_700_000;
|
|
109
|
+
|
|
110
|
+
IWETH9 constant WETH = IWETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
|
|
111
|
+
IUniswapV3Factory constant V3_FACTORY = IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984);
|
|
112
|
+
IPermit2 constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
|
|
113
|
+
IPoolManager constant V4_POOL_MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90);
|
|
114
|
+
|
|
115
|
+
address multisig = address(0xBEEF);
|
|
116
|
+
address payer = makeAddr("payer");
|
|
117
|
+
address trustedForwarder = address(0);
|
|
118
|
+
|
|
119
|
+
JBPermissions jbPermissions;
|
|
120
|
+
JBProjects jbProjects;
|
|
121
|
+
JBDirectory jbDirectory;
|
|
122
|
+
JBRulesets jbRulesets;
|
|
123
|
+
JBTokens jbTokens;
|
|
124
|
+
JBSplits jbSplits;
|
|
125
|
+
JBPrices jbPrices;
|
|
126
|
+
JBFundAccessLimits jbFundAccessLimits;
|
|
127
|
+
JBFeelessAddresses jbFeelessAddresses;
|
|
128
|
+
JBController jbController;
|
|
129
|
+
JBTerminalStore jbTerminalStore;
|
|
130
|
+
JBMultiTerminal jbMultiTerminal;
|
|
131
|
+
JBRouterTerminal routerTerminal;
|
|
132
|
+
|
|
133
|
+
MockFoTToken fotToken;
|
|
134
|
+
uint256 feeProjectId;
|
|
135
|
+
uint256 fotProjectId;
|
|
136
|
+
|
|
137
|
+
function setUp() public {
|
|
138
|
+
vm.createSelectFork("ethereum", BLOCK_NUMBER);
|
|
139
|
+
|
|
140
|
+
_deployJbCore();
|
|
141
|
+
|
|
142
|
+
routerTerminal = new JBRouterTerminal({
|
|
143
|
+
directory: jbDirectory,
|
|
144
|
+
tokens: jbTokens,
|
|
145
|
+
permit2: PERMIT2,
|
|
146
|
+
weth: WETH,
|
|
147
|
+
factory: V3_FACTORY,
|
|
148
|
+
poolManager: V4_POOL_MANAGER,
|
|
149
|
+
buybackHook: address(0),
|
|
150
|
+
univ4Hook: address(0),
|
|
151
|
+
trustedForwarder: trustedForwarder
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Deploy mock FOT token (1% fee per transfer).
|
|
155
|
+
fotToken = new MockFoTToken(100);
|
|
156
|
+
|
|
157
|
+
// Fee project.
|
|
158
|
+
feeProjectId = _launchProject(JBConstants.NATIVE_TOKEN, 18);
|
|
159
|
+
|
|
160
|
+
// Project that accepts the FOT token.
|
|
161
|
+
fotProjectId = _launchProject(address(fotToken), 18);
|
|
162
|
+
|
|
163
|
+
vm.label(address(fotToken), "FOT");
|
|
164
|
+
vm.label(address(routerTerminal), "RouterTerminal");
|
|
165
|
+
vm.label(address(jbMultiTerminal), "JBMultiTerminal");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
169
|
+
// FOT: Direct forward (no swap) — router pays project that accepts FOT
|
|
170
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
171
|
+
|
|
172
|
+
/// @notice FOT direct forward reverts because the second transfer (router → terminal)
|
|
173
|
+
/// loses tokens to the fee, causing a receipt mismatch.
|
|
174
|
+
///
|
|
175
|
+
/// Flow: payer sends 10,000 FOT → router receives 9,900 (1% fee) →
|
|
176
|
+
/// router approves terminal for 9,900 → terminal pulls 9,900 from router →
|
|
177
|
+
/// terminal receives 9,801 (1% fee) → receipt check: 9,801 != 9,900 → REVERT.
|
|
178
|
+
function test_fork_fotDirectForward_reverts() public {
|
|
179
|
+
uint256 amount = 10_000e18;
|
|
180
|
+
fotToken.mint(payer, amount);
|
|
181
|
+
|
|
182
|
+
vm.startPrank(payer);
|
|
183
|
+
fotToken.approve(address(routerTerminal), amount);
|
|
184
|
+
|
|
185
|
+
// Reverts with NonStandardTerminalToken because of receipt mismatch.
|
|
186
|
+
vm.expectRevert(JBRouterTerminal.JBRouterTerminal_NonStandardTerminalToken.selector);
|
|
187
|
+
routerTerminal.pay({
|
|
188
|
+
projectId: fotProjectId,
|
|
189
|
+
token: address(fotToken),
|
|
190
|
+
amount: amount,
|
|
191
|
+
beneficiary: payer,
|
|
192
|
+
minReturnedTokens: 0,
|
|
193
|
+
memo: "FOT direct forward",
|
|
194
|
+
metadata: ""
|
|
195
|
+
});
|
|
196
|
+
vm.stopPrank();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// @notice FOT addToBalanceOf also reverts for the same receipt mismatch reason.
|
|
200
|
+
function test_fork_fotAddToBalance_reverts() public {
|
|
201
|
+
uint256 amount = 5000e18;
|
|
202
|
+
fotToken.mint(payer, amount);
|
|
203
|
+
|
|
204
|
+
vm.startPrank(payer);
|
|
205
|
+
fotToken.approve(address(routerTerminal), amount);
|
|
206
|
+
|
|
207
|
+
vm.expectRevert(JBRouterTerminal.JBRouterTerminal_NonStandardTerminalToken.selector);
|
|
208
|
+
routerTerminal.addToBalanceOf({
|
|
209
|
+
projectId: fotProjectId,
|
|
210
|
+
token: address(fotToken),
|
|
211
|
+
amount: amount,
|
|
212
|
+
shouldReturnHeldFees: false,
|
|
213
|
+
memo: "FOT add to balance",
|
|
214
|
+
metadata: ""
|
|
215
|
+
});
|
|
216
|
+
vm.stopPrank();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
220
|
+
// Standard ERC20: Direct forward works (control test)
|
|
221
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
222
|
+
|
|
223
|
+
/// @notice Standard (non-FOT) ERC20 direct forward succeeds — proves the test
|
|
224
|
+
/// infrastructure is correct and only FOT causes the revert.
|
|
225
|
+
function test_fork_standardDirectForward_succeeds() public {
|
|
226
|
+
uint256 amount = 1 ether;
|
|
227
|
+
|
|
228
|
+
vm.deal(payer, amount);
|
|
229
|
+
vm.prank(payer);
|
|
230
|
+
uint256 tokenCount = routerTerminal.pay{value: amount}({
|
|
231
|
+
projectId: feeProjectId,
|
|
232
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
233
|
+
amount: amount,
|
|
234
|
+
beneficiary: payer,
|
|
235
|
+
minReturnedTokens: 0,
|
|
236
|
+
memo: "standard ETH direct forward",
|
|
237
|
+
metadata: ""
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
assertGt(tokenCount, 0, "standard token should mint");
|
|
241
|
+
assertEq(address(routerTerminal).balance, 0, "router should have no leftover ETH");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
245
|
+
// FOT: ETH payment to FOT-accepting project (swap path)
|
|
246
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
247
|
+
|
|
248
|
+
/// @notice Paying ETH to a project that only accepts FOT reverts because
|
|
249
|
+
/// no Uniswap pool exists for ETH/FOT.
|
|
250
|
+
/// @dev Bare expectRevert here: the specific error varies by route discovery path
|
|
251
|
+
/// (NoPoolFound vs NoRouteFound) depending on internal resolver logic.
|
|
252
|
+
function test_fork_fotSwapPath_noPoolReverts() public {
|
|
253
|
+
uint256 amount = 1 ether;
|
|
254
|
+
|
|
255
|
+
vm.deal(payer, amount);
|
|
256
|
+
vm.prank(payer);
|
|
257
|
+
vm.expectRevert();
|
|
258
|
+
routerTerminal.pay{value: amount}({
|
|
259
|
+
projectId: fotProjectId,
|
|
260
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
261
|
+
amount: amount,
|
|
262
|
+
beneficiary: payer,
|
|
263
|
+
minReturnedTokens: 0,
|
|
264
|
+
memo: "ETH to FOT project - no pool",
|
|
265
|
+
metadata: ""
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
270
|
+
// Internal helpers
|
|
271
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
272
|
+
|
|
273
|
+
function _deployJbCore() internal {
|
|
274
|
+
jbPermissions = new JBPermissions(trustedForwarder);
|
|
275
|
+
jbProjects = new JBProjects(multisig, address(0), trustedForwarder);
|
|
276
|
+
jbDirectory = new JBDirectory(jbPermissions, jbProjects, multisig);
|
|
277
|
+
JBERC20 jbErc20 = new JBERC20(jbPermissions, jbProjects);
|
|
278
|
+
jbTokens = new JBTokens(jbDirectory, jbErc20);
|
|
279
|
+
jbRulesets = new JBRulesets(jbDirectory);
|
|
280
|
+
jbPrices = new JBPrices(jbDirectory, jbPermissions, jbProjects, multisig, trustedForwarder);
|
|
281
|
+
jbSplits = new JBSplits(jbDirectory);
|
|
282
|
+
jbFundAccessLimits = new JBFundAccessLimits(jbDirectory);
|
|
283
|
+
jbFeelessAddresses = new JBFeelessAddresses(multisig);
|
|
284
|
+
|
|
285
|
+
jbController = new JBController(
|
|
286
|
+
jbDirectory,
|
|
287
|
+
jbFundAccessLimits,
|
|
288
|
+
jbPermissions,
|
|
289
|
+
jbPrices,
|
|
290
|
+
jbProjects,
|
|
291
|
+
jbRulesets,
|
|
292
|
+
jbSplits,
|
|
293
|
+
jbTokens,
|
|
294
|
+
address(0),
|
|
295
|
+
trustedForwarder
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
vm.prank(multisig);
|
|
299
|
+
jbDirectory.setIsAllowedToSetFirstController(address(jbController), true);
|
|
300
|
+
|
|
301
|
+
jbTerminalStore = new JBTerminalStore(jbDirectory, jbPrices, jbRulesets);
|
|
302
|
+
|
|
303
|
+
jbMultiTerminal = new JBMultiTerminal(
|
|
304
|
+
jbFeelessAddresses,
|
|
305
|
+
jbPermissions,
|
|
306
|
+
jbProjects,
|
|
307
|
+
jbSplits,
|
|
308
|
+
jbTerminalStore,
|
|
309
|
+
jbTokens,
|
|
310
|
+
PERMIT2,
|
|
311
|
+
trustedForwarder
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function _launchProject(address acceptedToken, uint8 decimals) internal returns (uint256 projectId) {
|
|
316
|
+
JBRulesetMetadata memory metadata = JBRulesetMetadata({
|
|
317
|
+
reservedPercent: 0,
|
|
318
|
+
cashOutTaxRate: 0,
|
|
319
|
+
baseCurrency: uint32(uint160(acceptedToken)),
|
|
320
|
+
pausePay: false,
|
|
321
|
+
pauseCreditTransfers: false,
|
|
322
|
+
allowOwnerMinting: false,
|
|
323
|
+
allowSetCustomToken: false,
|
|
324
|
+
allowTerminalMigration: false,
|
|
325
|
+
allowSetTerminals: false,
|
|
326
|
+
allowSetController: false,
|
|
327
|
+
allowAddAccountingContext: false,
|
|
328
|
+
allowAddPriceFeed: false,
|
|
329
|
+
ownerMustSendPayouts: false,
|
|
330
|
+
holdFees: false,
|
|
331
|
+
useTotalSurplusForCashOuts: false,
|
|
332
|
+
useDataHookForPay: false,
|
|
333
|
+
useDataHookForCashOut: false,
|
|
334
|
+
dataHook: address(0),
|
|
335
|
+
metadata: 0
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
JBRulesetConfig[] memory rulesetConfigs = new JBRulesetConfig[](1);
|
|
339
|
+
rulesetConfigs[0].mustStartAtOrAfter = 0;
|
|
340
|
+
rulesetConfigs[0].duration = 0;
|
|
341
|
+
rulesetConfigs[0].weight = 1_000_000e18;
|
|
342
|
+
rulesetConfigs[0].weightCutPercent = 0;
|
|
343
|
+
rulesetConfigs[0].approvalHook = IJBRulesetApprovalHook(address(0));
|
|
344
|
+
rulesetConfigs[0].metadata = metadata;
|
|
345
|
+
rulesetConfigs[0].splitGroups = new JBSplitGroup[](0);
|
|
346
|
+
rulesetConfigs[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
347
|
+
|
|
348
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
349
|
+
tokensToAccept[0] =
|
|
350
|
+
JBAccountingContext({token: acceptedToken, decimals: decimals, currency: uint32(uint160(acceptedToken))});
|
|
351
|
+
|
|
352
|
+
JBTerminalConfig[] memory terminalConfigs = new JBTerminalConfig[](1);
|
|
353
|
+
terminalConfigs[0] = JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: tokensToAccept});
|
|
354
|
+
|
|
355
|
+
projectId = jbController.launchProjectFor({
|
|
356
|
+
owner: multisig,
|
|
357
|
+
projectUri: "test-project",
|
|
358
|
+
rulesetConfigurations: rulesetConfigs,
|
|
359
|
+
terminalConfigurations: terminalConfigs,
|
|
360
|
+
memo: ""
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|