@bananapus/721-hook-v6 0.0.42 → 0.0.45

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.
Files changed (86) hide show
  1. package/foundry.lock +1 -7
  2. package/foundry.toml +1 -1
  3. package/package.json +20 -9
  4. package/script/Deploy.s.sol +2 -2
  5. package/src/JB721Checkpoints.sol +61 -19
  6. package/src/JB721CheckpointsDeployer.sol +10 -5
  7. package/src/JB721TiersHook.sol +66 -53
  8. package/src/JB721TiersHookDeployer.sol +8 -5
  9. package/src/JB721TiersHookProjectDeployer.sol +87 -46
  10. package/src/JB721TiersHookStore.sol +137 -107
  11. package/src/abstract/JB721Hook.sol +8 -6
  12. package/src/interfaces/IJB721Checkpoints.sol +21 -14
  13. package/src/interfaces/IJB721CheckpointsDeployer.sol +7 -3
  14. package/src/interfaces/IJB721TiersHook.sol +3 -3
  15. package/src/interfaces/IJB721TiersHookProjectDeployer.sol +4 -2
  16. package/src/interfaces/IJB721TiersHookStore.sol +11 -11
  17. package/src/libraries/JB721TiersHookLib.sol +1 -1
  18. package/src/structs/JB721TiersHookFlags.sol +1 -1
  19. package/src/structs/JBPayDataHookRulesetMetadata.sol +1 -1
  20. package/test/utils/AccessJBLib.sol +49 -0
  21. package/test/utils/ForTest_JB721TiersHook.sol +246 -0
  22. package/test/utils/TestBaseWorkflow.sol +213 -0
  23. package/test/utils/UnitTestSetup.sol +805 -0
  24. package/.gas-snapshot +0 -152
  25. package/ADMINISTRATION.md +0 -87
  26. package/ARCHITECTURE.md +0 -98
  27. package/AUDIT_INSTRUCTIONS.md +0 -77
  28. package/RISKS.md +0 -118
  29. package/SKILLS.md +0 -43
  30. package/STYLE_GUIDE.md +0 -610
  31. package/USER_JOURNEYS.md +0 -121
  32. package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +0 -83
  33. package/slither-ci.config.json +0 -10
  34. package/test/721HookAttacks.t.sol +0 -408
  35. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +0 -985
  36. package/test/Fork.t.sol +0 -2346
  37. package/test/TestAuditGaps.sol +0 -1075
  38. package/test/TestCheckpoints.t.sol +0 -341
  39. package/test/TestSafeTransferReentrancy.t.sol +0 -305
  40. package/test/TestVotingUnitsLifecycle.t.sol +0 -313
  41. package/test/audit/AuditRegressions.t.sol +0 -83
  42. package/test/audit/CodexNemesisReserveSellout.t.sol +0 -66
  43. package/test/audit/CrossCurrencySplitNoPrices.t.sol +0 -123
  44. package/test/audit/FreshAudit.t.sol +0 -197
  45. package/test/audit/FutureTierPoC.t.sol +0 -39
  46. package/test/audit/FutureTierRemoval.t.sol +0 -47
  47. package/test/audit/Pass12L18.t.sol +0 -80
  48. package/test/audit/PayCreditsBypassTierSplits.t.sol +0 -200
  49. package/test/audit/ProjectDeployerAuth.t.sol +0 -266
  50. package/test/audit/RepoFindings.t.sol +0 -195
  51. package/test/audit/ReserveActivation.t.sol +0 -87
  52. package/test/audit/ReserveSlotProtection.t.sol +0 -273
  53. package/test/audit/RetroactiveReserveBeneficiaryDilution.t.sol +0 -149
  54. package/test/audit/SameCurrencyDecimalMismatch.t.sol +0 -249
  55. package/test/audit/SplitCreditsMismatch.t.sol +0 -219
  56. package/test/audit/SplitFailureRedistribution.t.sol +0 -143
  57. package/test/audit/USDTVoidReturnCompat.t.sol +0 -301
  58. package/test/fork/ERC20CashOutFork.t.sol +0 -633
  59. package/test/fork/ERC20TierSplitFork.t.sol +0 -596
  60. package/test/fork/IssueTokensForSplitsFork.t.sol +0 -516
  61. package/test/invariants/TierLifecycleInvariant.t.sol +0 -188
  62. package/test/invariants/TieredHookStoreInvariant.t.sol +0 -86
  63. package/test/invariants/handlers/TierLifecycleHandler.sol +0 -300
  64. package/test/invariants/handlers/TierStoreHandler.sol +0 -165
  65. package/test/regression/BrokenTerminalDoesNotDos.t.sol +0 -277
  66. package/test/regression/CacheTierLookup.t.sol +0 -190
  67. package/test/regression/ProjectDeployerRulesets.t.sol +0 -358
  68. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +0 -155
  69. package/test/regression/SplitDistributionBugs.t.sol +0 -751
  70. package/test/regression/SplitNoBeneficiary.t.sol +0 -140
  71. package/test/unit/AuditFixes_Unit.t.sol +0 -624
  72. package/test/unit/JB721CheckpointsDeployer_AccessControl.t.sol +0 -116
  73. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +0 -144
  74. package/test/unit/JBBitmap.t.sol +0 -170
  75. package/test/unit/JBIpfsDecoder.t.sol +0 -136
  76. package/test/unit/TierSupplyReserveCheck.t.sol +0 -221
  77. package/test/unit/adjustTier_Unit.t.sol +0 -1942
  78. package/test/unit/deployer_Unit.t.sol +0 -114
  79. package/test/unit/getters_constructor_Unit.t.sol +0 -593
  80. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +0 -452
  81. package/test/unit/pay_CrossCurrency_Unit.t.sol +0 -530
  82. package/test/unit/pay_Unit.t.sol +0 -1661
  83. package/test/unit/redeem_Unit.t.sol +0 -473
  84. package/test/unit/relayBeneficiary_Unit.t.sol +0 -182
  85. package/test/unit/splitHookDistribution_Unit.t.sol +0 -604
  86. package/test/unit/tierSplitRouting_Unit.t.sol +0 -757
@@ -1,219 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- // forge-lint: disable-next-line(unaliased-plain-import)
5
- import "../utils/UnitTestSetup.sol";
6
- import {IJB721TiersHookStore} from "../../src/interfaces/IJB721TiersHookStore.sol";
7
- import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
- import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
9
- import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
10
- import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
11
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
12
- import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
13
- import {JB721TierConfigFlags} from "../../src/structs/JB721TierConfigFlags.sol";
14
-
15
- /// @notice Regression test: split metadata is proportionally scaled when credits fund a split-bearing tier mint.
16
- /// @dev Previously (pre-fix), the per-tier split amounts were left at the uncapped value, trapping forwarded ETH.
17
- /// After the F-2 fix, split amounts are scaled down to match the actual forwarded amount.
18
- contract SplitCreditsMismatch is UnitTestSetup {
19
- address internal splitBeneficiary = makeAddr("splitBeneficiary");
20
-
21
- function setUp() public override {
22
- super.setUp();
23
- vm.etch(mockJBSplits, new bytes(0x69));
24
- }
25
-
26
- function _buildPayMetadata(
27
- address hookAddress,
28
- bool allowOverspending,
29
- uint16[] memory tierIdsToMint
30
- )
31
- internal
32
- view
33
- returns (bytes memory)
34
- {
35
- bytes[] memory data = new bytes[](1);
36
- data[0] = abi.encode(allowOverspending, tierIdsToMint);
37
- bytes4[] memory ids = new bytes4[](1);
38
- ids[0] = metadataHelper.getId("pay", hookAddress);
39
- return metadataHelper.createMetadata(ids, data);
40
- }
41
-
42
- function _beforePayContext(
43
- address hookAddress,
44
- uint256 amountValue,
45
- uint16[] memory tierIdsToMint
46
- )
47
- internal
48
- view
49
- returns (JBBeforePayRecordedContext memory)
50
- {
51
- return JBBeforePayRecordedContext({
52
- terminal: mockTerminalAddress,
53
- payer: beneficiary,
54
- amount: JBTokenAmount({
55
- token: JBConstants.NATIVE_TOKEN,
56
- value: amountValue,
57
- decimals: 18,
58
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
59
- }),
60
- projectId: projectId,
61
- rulesetId: 0,
62
- beneficiary: beneficiary,
63
- weight: 10e18,
64
- reservedPercent: 5000,
65
- metadata: _buildPayMetadata(hookAddress, false, tierIdsToMint)
66
- });
67
- }
68
-
69
- function _afterPayContext(
70
- address hookAddress,
71
- uint256 amountValue,
72
- uint256 forwardedAmountValue,
73
- bytes memory hookMetadata,
74
- uint16[] memory tierIdsToMint
75
- )
76
- internal
77
- view
78
- returns (JBAfterPayRecordedContext memory)
79
- {
80
- return JBAfterPayRecordedContext({
81
- payer: beneficiary,
82
- projectId: projectId,
83
- rulesetId: 0,
84
- amount: JBTokenAmount({
85
- token: JBConstants.NATIVE_TOKEN,
86
- value: amountValue,
87
- decimals: 18,
88
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
89
- }),
90
- forwardedAmount: JBTokenAmount({
91
- token: JBConstants.NATIVE_TOKEN,
92
- value: forwardedAmountValue,
93
- decimals: 18,
94
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
95
- }),
96
- weight: 10e18,
97
- newlyIssuedTokenCount: 0,
98
- beneficiary: beneficiary,
99
- hookMetadata: hookMetadata,
100
- payerMetadata: _buildPayMetadata(hookAddress, true, tierIdsToMint)
101
- });
102
- }
103
-
104
- function test_payCreditsScaleSplitMetadata_andForwardEth() public {
105
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
106
- IJB721TiersHookStore hookStore = testHook.STORE();
107
-
108
- vm.mockCall(
109
- mockJBDirectory,
110
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
111
- abi.encode(true)
112
- );
113
-
114
- // Tier costs 1 ether and routes 100% of its effective price to splits.
115
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
116
- tierConfigs[0] = JB721TierConfig({
117
- price: 1 ether,
118
- initialSupply: 100,
119
- votingUnits: 0,
120
- reserveFrequency: 0,
121
- reserveBeneficiary: reserveBeneficiary,
122
- encodedIPFSUri: bytes32(uint256(0x1234)),
123
- category: 1,
124
- discountPercent: 0,
125
- flags: JB721TierConfigFlags({
126
- allowOwnerMint: false,
127
- useReserveBeneficiaryAsDefault: false,
128
- transfersPausable: false,
129
- useVotingUnits: false,
130
- cantBeRemoved: false,
131
- cantIncreaseDiscountPercent: false,
132
- cantBuyWithCredits: false
133
- }),
134
- splitPercent: 1_000_000_000,
135
- splits: new JBSplit[](0)
136
- });
137
-
138
- vm.prank(address(testHook));
139
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
140
-
141
- // Seed 1 ether of pay credits with an earlier overpayment.
142
- uint16[] memory noTiers = new uint16[](0);
143
- JBAfterPayRecordedContext memory creditSeedContext = JBAfterPayRecordedContext({
144
- payer: beneficiary,
145
- projectId: projectId,
146
- rulesetId: 0,
147
- amount: JBTokenAmount({
148
- token: JBConstants.NATIVE_TOKEN,
149
- value: 1 ether,
150
- decimals: 18,
151
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
152
- }),
153
- forwardedAmount: JBTokenAmount({
154
- token: JBConstants.NATIVE_TOKEN,
155
- value: 0,
156
- decimals: 18,
157
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
158
- }),
159
- weight: 10e18,
160
- newlyIssuedTokenCount: 0,
161
- beneficiary: beneficiary,
162
- hookMetadata: "",
163
- payerMetadata: _buildPayMetadata(address(testHook), true, noTiers)
164
- });
165
- vm.prank(mockTerminalAddress);
166
- testHook.afterPayRecordedWith(creditSeedContext);
167
- assertEq(testHook.payCreditsOf(beneficiary), 1 ether, "setup: pay credits should be seeded");
168
-
169
- uint16[] memory mintIds = new uint16[](1);
170
- mintIds[0] = uint16(tierIds[0]);
171
-
172
- // beforePay caps the forwarded amount to the actual payment...
173
- (, JBPayHookSpecification[] memory specs) =
174
- testHook.beforePayRecordedWith(_beforePayContext(address(testHook), 1, mintIds));
175
- assertEq(specs[0].amount, 1, "forwarded amount should be capped to actual payment");
176
-
177
- // ...and proportionally scales the encoded per-tier split amounts to match the capped total.
178
- (,, bytes memory splitData) = abi.decode(specs[0].metadata, (address, address, bytes));
179
- (, uint256[] memory encodedAmounts) = abi.decode(splitData, (uint16[], uint256[]));
180
- assertEq(encodedAmounts.length, 1, "expected one encoded split amount");
181
- assertEq(encodedAmounts[0], 1, "hook metadata should be scaled down to match forwarded amount");
182
-
183
- // Route the split to a beneficiary and make project-balance fallback unavailable.
184
- JBSplit[] memory splits = new JBSplit[](1);
185
- splits[0] = JBSplit({
186
- percent: 1_000_000_000,
187
- projectId: 0,
188
- beneficiary: payable(splitBeneficiary),
189
- preferAddToBalance: false,
190
- lockedUntil: 0,
191
- hook: IJBSplitHook(address(0))
192
- });
193
-
194
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
195
- vm.mockCall(
196
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
197
- );
198
- vm.mockCall(
199
- mockJBDirectory,
200
- abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, projectId, JBConstants.NATIVE_TOKEN),
201
- abi.encode(address(0))
202
- );
203
-
204
- uint256 splitBeneficiaryBalanceBefore = splitBeneficiary.balance;
205
-
206
- // The mint succeeds because pay credits cover the price. With the fix, the split IS honored.
207
- JBAfterPayRecordedContext memory creditMintContext =
208
- _afterPayContext(address(testHook), 1, 1, specs[0].metadata, mintIds);
209
- vm.deal(mockTerminalAddress, 1);
210
- vm.prank(mockTerminalAddress);
211
- testHook.afterPayRecordedWith{value: 1}(creditMintContext);
212
-
213
- assertEq(testHook.balanceOf(beneficiary), 1, "beneficiary should still receive the NFT");
214
- assertEq(testHook.payCreditsOf(beneficiary), 1, "only 1 wei of credits should remain");
215
- // After fix: split beneficiary receives the forwarded ETH, nothing trapped.
216
- assertEq(splitBeneficiary.balance - splitBeneficiaryBalanceBefore, 1, "split beneficiary should receive 1 wei");
217
- assertEq(address(testHook).balance, 0, "no ETH should be trapped in the hook");
218
- }
219
- }
@@ -1,143 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- // forge-lint: disable-next-line(unaliased-plain-import)
5
- import "../utils/UnitTestSetup.sol";
6
- import {IJB721TiersHookStore} from "../../src/interfaces/IJB721TiersHookStore.sol";
7
- import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
8
- import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
9
- import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
10
- import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
11
-
12
- contract SplitFailureRedistribution is UnitTestSetup {
13
- address internal alice = makeAddr("alice");
14
- address internal bob = makeAddr("bob");
15
-
16
- function test_failedEarlierSplit_doesNotOverpayLaterSplit() public {
17
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
18
- IJB721TiersHookStore hookStore = testHook.STORE();
19
-
20
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
21
- tierConfigs[0].price = 1 ether;
22
- tierConfigs[0].initialSupply = uint32(100);
23
- tierConfigs[0].category = uint24(1);
24
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
25
- tierConfigs[0].splitPercent = 1_000_000_000;
26
-
27
- vm.prank(address(testHook));
28
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
29
-
30
- mockAndExpect(
31
- address(mockJBDirectory),
32
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
33
- abi.encode(true)
34
- );
35
-
36
- RevertOnReceive revertingBeneficiary = new RevertOnReceive();
37
-
38
- JBSplit[] memory splits = new JBSplit[](2);
39
- splits[0] = JBSplit({
40
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT / 2),
41
- projectId: 0,
42
- beneficiary: payable(address(revertingBeneficiary)),
43
- preferAddToBalance: false,
44
- lockedUntil: 0,
45
- hook: IJBSplitHook(address(0))
46
- });
47
- splits[1] = JBSplit({
48
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT / 2),
49
- projectId: 0,
50
- beneficiary: payable(bob),
51
- preferAddToBalance: false,
52
- lockedUntil: 0,
53
- hook: IJBSplitHook(address(0))
54
- });
55
-
56
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
57
- mockAndExpect(
58
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
59
- );
60
-
61
- bytes memory payerMetadata = _buildPayMetadata(address(testHook), uint16(tierIds[0]));
62
- bytes memory hookMetadata =
63
- abi.encode(beneficiary, beneficiary, abi.encode(_singleTierId(uint16(tierIds[0])), _singleAmount(1 ether)));
64
-
65
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
66
- payer: beneficiary,
67
- projectId: projectId,
68
- rulesetId: 0,
69
- amount: JBTokenAmount({
70
- token: JBConstants.NATIVE_TOKEN,
71
- value: 1 ether,
72
- decimals: 18,
73
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
74
- }),
75
- forwardedAmount: JBTokenAmount({
76
- token: JBConstants.NATIVE_TOKEN,
77
- value: 1 ether,
78
- decimals: 18,
79
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
80
- }),
81
- weight: 10e18,
82
- newlyIssuedTokenCount: 0,
83
- beneficiary: beneficiary,
84
- hookMetadata: hookMetadata,
85
- payerMetadata: payerMetadata
86
- });
87
-
88
- // Mock primaryTerminalOf so the leftover (failed split amount) can be routed to the project balance.
89
- address projectTerminal = makeAddr("projectTerminal");
90
- vm.etch(projectTerminal, new bytes(0x69));
91
- mockAndExpect(
92
- address(mockJBDirectory),
93
- abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, projectId, JBConstants.NATIVE_TOKEN),
94
- abi.encode(projectTerminal)
95
- );
96
- // Mock addToBalanceOf on the terminal so the leftover deposit succeeds.
97
- vm.mockCall(
98
- projectTerminal,
99
- abi.encodeWithSelector(
100
- IJBTerminal.addToBalanceOf.selector, projectId, JBConstants.NATIVE_TOKEN, 0.5 ether, false, "", ""
101
- ),
102
- ""
103
- );
104
-
105
- uint256 bobBalanceBefore = bob.balance;
106
-
107
- vm.deal(mockTerminalAddress, 1 ether);
108
- vm.prank(mockTerminalAddress);
109
- testHook.afterPayRecordedWith{value: 1 ether}(payContext);
110
-
111
- // With the fix, Bob only receives his fair 50% share. The failed split's 0.5 ether
112
- // is routed to the project's balance via addToBalanceOf on the primary terminal.
113
- assertEq(
114
- bob.balance - bobBalanceBefore,
115
- 0.5 ether,
116
- "later split should only receive its own allocation, not the failed split's share"
117
- );
118
- }
119
-
120
- function _buildPayMetadata(address hookAddress, uint16 tierId) internal view returns (bytes memory) {
121
- bytes[] memory data = new bytes[](1);
122
- data[0] = abi.encode(false, _singleTierId(tierId));
123
- bytes4[] memory ids = new bytes4[](1);
124
- ids[0] = metadataHelper.getId("pay", hookAddress);
125
- return metadataHelper.createMetadata(ids, data);
126
- }
127
-
128
- function _singleTierId(uint16 tierId) internal pure returns (uint16[] memory tierIds) {
129
- tierIds = new uint16[](1);
130
- tierIds[0] = tierId;
131
- }
132
-
133
- function _singleAmount(uint256 amount) internal pure returns (uint256[] memory amounts) {
134
- amounts = new uint256[](1);
135
- amounts[0] = amount;
136
- }
137
- }
138
-
139
- contract RevertOnReceive {
140
- receive() external payable {
141
- revert("NO_ETH");
142
- }
143
- }
@@ -1,301 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- // Import the forge-std test framework.
5
- import {Test} from "forge-std/Test.sol";
6
- // Import IERC20 for the encodeCall used in the low-level call pattern.
7
- import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
8
-
9
- /// @notice Mimics USDT's void-returning transfer/transferFrom/approve behavior.
10
- /// @dev Identical to nana-core-v6/test/mock/MockUSDT.sol, inlined here to avoid cross-repo imports.
11
- contract MockUSDT {
12
- // Token metadata matching USDT's 6-decimal convention.
13
- string public name = "Mock Tether USD";
14
- // Short ticker symbol for the mock token.
15
- string public symbol = "USDT";
16
- // USDT uses 6 decimals, not 18 like most ERC-20 tokens.
17
- uint8 public decimals = 6;
18
- // Running total of all minted tokens.
19
- uint256 public totalSupply;
20
-
21
- // Maps each address to its token balance.
22
- mapping(address => uint256) public balanceOf;
23
- // Maps owner => spender => allowance for delegated transfers.
24
- mapping(address => mapping(address => uint256)) public allowance;
25
-
26
- /// @notice Mints tokens to a recipient (test helper, not part of USDT interface).
27
- /// @param to The address to receive newly minted tokens.
28
- /// @param amount The number of tokens to mint.
29
- function mint(address to, uint256 amount) external {
30
- // Credit the recipient's balance with the minted amount.
31
- balanceOf[to] += amount;
32
- // Increase the total supply to reflect the new tokens.
33
- totalSupply += amount;
34
- }
35
-
36
- /// @notice Sets the spender's allowance. Returns VOID like real USDT.
37
- /// @param spender The address authorized to spend tokens.
38
- /// @param amount The maximum amount the spender can transfer.
39
- function approve(address spender, uint256 amount) external {
40
- // Record the new allowance for the caller-spender pair.
41
- allowance[msg.sender][spender] = amount;
42
- // Use assembly to return without any data (void), matching USDT behavior.
43
- assembly {
44
- return(0, 0)
45
- }
46
- }
47
-
48
- /// @notice Transfers tokens from caller to recipient. Returns VOID like real USDT.
49
- /// @param to The address to receive the tokens.
50
- /// @param amount The number of tokens to transfer.
51
- function transfer(address to, uint256 amount) external {
52
- // Ensure the sender has enough tokens to cover the transfer.
53
- require(balanceOf[msg.sender] >= amount, "MockUSDT: insufficient balance");
54
- // Debit the sender's balance by the transfer amount.
55
- balanceOf[msg.sender] -= amount;
56
- // Credit the recipient's balance with the transferred tokens.
57
- balanceOf[to] += amount;
58
- // Use assembly to return without any data (void), matching USDT behavior.
59
- assembly {
60
- return(0, 0)
61
- }
62
- }
63
-
64
- /// @notice Transfers tokens on behalf of an owner. Returns VOID like real USDT.
65
- /// @param from The address whose tokens are being spent.
66
- /// @param to The address to receive the tokens.
67
- /// @param amount The number of tokens to transfer.
68
- function transferFrom(address from, address to, uint256 amount) external {
69
- // Ensure the owner has enough tokens for the transfer.
70
- require(balanceOf[from] >= amount, "MockUSDT: insufficient balance");
71
- // Ensure the caller is authorized to spend at least this amount.
72
- require(allowance[from][msg.sender] >= amount, "MockUSDT: insufficient allowance");
73
- // Reduce the caller's remaining allowance by the transferred amount.
74
- allowance[from][msg.sender] -= amount;
75
- // Debit the owner's balance by the transfer amount.
76
- balanceOf[from] -= amount;
77
- // Credit the recipient's balance with the transferred tokens.
78
- balanceOf[to] += amount;
79
- // Use assembly to return without any data (void), matching USDT behavior.
80
- assembly {
81
- return(0, 0)
82
- }
83
- }
84
- }
85
-
86
- /// @notice Tests that JB721TiersHookLib's low-level call pattern (lines 605-611) handles
87
- /// void-returning tokens like USDT correctly.
88
- /// @dev The fix replaced `try IERC20.transfer()` with a low-level call that checks:
89
- /// 1. The call succeeded (callSuccess == true)
90
- /// 2. Either no data was returned (void) OR the returned data decodes to true
91
- /// This test exercises that exact pattern against a void-returning mock.
92
- contract USDTVoidReturnCompat is Test {
93
- // The USDT mock token instance.
94
- MockUSDT public usdt;
95
- // Address that simulates the hook contract holding tokens for distribution.
96
- address public hookCaller;
97
- // Address that receives split payout funds.
98
- address public splitBeneficiary;
99
-
100
- function setUp() public {
101
- // Deploy the void-returning USDT mock.
102
- usdt = new MockUSDT();
103
- // Label the USDT contract for clearer trace output.
104
- vm.label(address(usdt), "MockUSDT");
105
- // Create a caller address that simulates the hook distributing tokens.
106
- hookCaller = makeAddr("hookCaller");
107
- // Create a beneficiary address that receives split payouts.
108
- splitBeneficiary = makeAddr("splitBeneficiary");
109
- }
110
-
111
- // =========================================================================
112
- // Test 1: The exact low-level call pattern from JB721TiersHookLib works
113
- // with void-returning tokens
114
- // =========================================================================
115
-
116
- /// @notice Proves the fixed transfer pattern handles USDT's void return.
117
- /// @dev This replicates the exact code from JB721TiersHookLib lines 609-611:
118
- /// (bool callSuccess, bytes memory returndata) =
119
- /// address(token).call(abi.encodeCall(IERC20.transfer, (split.beneficiary, amount)));
120
- /// if (!callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)))) return false;
121
- function test_lowLevelTransfer_voidReturn_succeeds() public {
122
- // The amount to transfer in the split payout.
123
- uint256 amount = 500e6;
124
- // Mint USDT to the hook caller (simulating tokens held by the 721 hook).
125
- usdt.mint(hookCaller, amount);
126
-
127
- // Execute the exact low-level call pattern from JB721TiersHookLib.
128
- vm.prank(hookCaller);
129
- // Encode the transfer call exactly as the library does.
130
- (bool callSuccess, bytes memory returndata) =
131
- address(usdt).call(abi.encodeCall(IERC20.transfer, (splitBeneficiary, amount)));
132
-
133
- // Verify the low-level call did not revert.
134
- assertTrue(callSuccess, "Low-level transfer call should succeed");
135
-
136
- // Verify void return: USDT returns no data, so returndata.length should be 0.
137
- assertEq(returndata.length, 0, "Void-returning token should return empty data");
138
-
139
- // Verify the combined condition from line 611 evaluates correctly.
140
- // For void returns: callSuccess=true, returndata.length=0, so the condition is false (no revert).
141
- bool wouldRevert = !callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)));
142
- // The pattern should NOT flag this as a failure.
143
- assertFalse(wouldRevert, "The fixed pattern should accept void returns as success");
144
-
145
- // Verify the beneficiary actually received the tokens.
146
- assertEq(usdt.balanceOf(splitBeneficiary), amount, "Beneficiary should receive the full transfer amount");
147
- // Verify the caller's balance was debited.
148
- assertEq(usdt.balanceOf(hookCaller), 0, "Caller should have zero balance after transfer");
149
- }
150
-
151
- // =========================================================================
152
- // Test 2: The pattern also works with standard bool-returning tokens
153
- // =========================================================================
154
-
155
- /// @notice Ensures the low-level call pattern still works with compliant ERC-20 tokens.
156
- /// @dev A standard token returns abi.encode(true) — the pattern must accept this too.
157
- function test_lowLevelTransfer_boolReturn_succeeds() public {
158
- // Deploy a standard ERC-20 that returns bool (using forge's mock).
159
- StandardMockERC20 standardToken = new StandardMockERC20();
160
- // Label it for tracing.
161
- vm.label(address(standardToken), "StandardERC20");
162
- // The amount to transfer.
163
- uint256 amount = 500e6;
164
- // Mint tokens to the hook caller.
165
- standardToken.mint(hookCaller, amount);
166
-
167
- // Execute the exact low-level call pattern from JB721TiersHookLib.
168
- vm.prank(hookCaller);
169
- // Encode the transfer call.
170
- (bool callSuccess, bytes memory returndata) =
171
- address(standardToken).call(abi.encodeCall(IERC20.transfer, (splitBeneficiary, amount)));
172
-
173
- // Verify the low-level call succeeded.
174
- assertTrue(callSuccess, "Low-level transfer call should succeed for standard token");
175
-
176
- // Standard tokens return 32 bytes encoding `true`.
177
- assertEq(returndata.length, 32, "Standard token should return 32 bytes of data");
178
-
179
- // Verify the combined condition correctly accepts a true return.
180
- bool wouldRevert = !callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)));
181
- // Should NOT flag as failure since the decoded bool is true.
182
- assertFalse(wouldRevert, "The pattern should accept bool(true) returns as success");
183
-
184
- // Verify the beneficiary received the tokens.
185
- assertEq(standardToken.balanceOf(splitBeneficiary), amount, "Beneficiary should receive tokens");
186
- }
187
-
188
- // =========================================================================
189
- // Test 3: The pattern correctly rejects a return-false token
190
- // =========================================================================
191
-
192
- /// @notice Ensures the pattern detects when a token returns false.
193
- /// @dev A token that returns abi.encode(false) without reverting should be caught.
194
- function test_lowLevelTransfer_returnsFalse_detected() public {
195
- // Deploy a token that returns false on transfer.
196
- ReturnFalseToken falseToken = new ReturnFalseToken();
197
- // Label it for tracing.
198
- vm.label(address(falseToken), "ReturnFalseToken");
199
- // The amount doesn't matter since the token always returns false.
200
- uint256 amount = 500e6;
201
- // Mint tokens to the caller.
202
- falseToken.mint(hookCaller, amount);
203
-
204
- // Execute the exact low-level call pattern from JB721TiersHookLib.
205
- vm.prank(hookCaller);
206
- (bool callSuccess, bytes memory returndata) =
207
- address(falseToken).call(abi.encodeCall(IERC20.transfer, (splitBeneficiary, amount)));
208
-
209
- // The call itself succeeds (no revert), but the return value is false.
210
- assertTrue(callSuccess, "Low-level call should not revert");
211
-
212
- // Verify the combined condition catches the false return.
213
- bool wouldRevert = !callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)));
214
- // The pattern SHOULD flag this as a failure.
215
- assertTrue(wouldRevert, "The pattern should detect false return and flag failure");
216
- }
217
-
218
- // =========================================================================
219
- // Test 4: The pattern correctly handles a reverting token
220
- // =========================================================================
221
-
222
- /// @notice Ensures the pattern handles a reverting transfer gracefully.
223
- /// @dev If the transfer reverts, callSuccess is false, and the pattern returns false.
224
- function test_lowLevelTransfer_revert_detected() public {
225
- // Use MockUSDT but don't give the caller any tokens — transfer will revert.
226
- uint256 amount = 500e6;
227
-
228
- // Execute the low-level call with insufficient balance.
229
- vm.prank(hookCaller);
230
- (bool callSuccess,) = address(usdt).call(abi.encodeCall(IERC20.transfer, (splitBeneficiary, amount)));
231
-
232
- // The call should fail because the caller has zero balance.
233
- assertFalse(callSuccess, "Transfer should fail with insufficient balance");
234
- }
235
- }
236
-
237
- /// @notice A standard ERC-20 mock that returns true on transfer (compliant behavior).
238
- contract StandardMockERC20 {
239
- // Token name for identification.
240
- string public name = "Standard Mock";
241
- // Token symbol.
242
- string public symbol = "STD";
243
- // Uses 6 decimals to match USDT comparison.
244
- uint8 public decimals = 6;
245
- // Running total supply.
246
- uint256 public totalSupply;
247
-
248
- // Balance mapping.
249
- mapping(address => uint256) public balanceOf;
250
- // Allowance mapping.
251
- mapping(address => mapping(address => uint256)) public allowance;
252
-
253
- /// @notice Mints tokens to a recipient (test helper).
254
- function mint(address to, uint256 amount) external {
255
- // Credit the recipient.
256
- balanceOf[to] += amount;
257
- // Increase total supply.
258
- totalSupply += amount;
259
- }
260
-
261
- /// @notice Standard transfer that returns true.
262
- function transfer(address to, uint256 amount) external returns (bool) {
263
- // Check the sender has enough balance.
264
- require(balanceOf[msg.sender] >= amount, "insufficient balance");
265
- // Debit the sender.
266
- balanceOf[msg.sender] -= amount;
267
- // Credit the recipient.
268
- balanceOf[to] += amount;
269
- // Return true per ERC-20 spec.
270
- return true;
271
- }
272
- }
273
-
274
- /// @notice A mock ERC-20 that returns false on transfer without reverting.
275
- contract ReturnFalseToken {
276
- // Token name for identification.
277
- string public name = "Return False";
278
- // Token symbol.
279
- string public symbol = "RF";
280
- // Uses 6 decimals.
281
- uint8 public decimals = 6;
282
- // Running total supply.
283
- uint256 public totalSupply;
284
-
285
- // Balance mapping.
286
- mapping(address => uint256) public balanceOf;
287
-
288
- /// @notice Mints tokens to a recipient (test helper).
289
- function mint(address to, uint256 amount) external {
290
- // Credit the recipient.
291
- balanceOf[to] += amount;
292
- // Increase total supply.
293
- totalSupply += amount;
294
- }
295
-
296
- /// @notice Always returns false without reverting (malicious/broken token behavior).
297
- function transfer(address, uint256) external pure returns (bool) {
298
- // Return false to simulate a failed transfer that doesn't revert.
299
- return false;
300
- }
301
- }