@bananapus/721-hook-v6 0.0.19 → 0.0.21

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 (55) hide show
  1. package/ADMINISTRATION.md +39 -29
  2. package/ARCHITECTURE.md +48 -5
  3. package/AUDIT_INSTRUCTIONS.md +85 -12
  4. package/CHANGE_LOG.md +15 -1
  5. package/README.md +211 -210
  6. package/RISKS.md +18 -1
  7. package/SKILLS.md +107 -37
  8. package/STYLE_GUIDE.md +2 -2
  9. package/USER_JOURNEYS.md +44 -19
  10. package/foundry.toml +1 -1
  11. package/package.json +5 -5
  12. package/script/Deploy.s.sol +1 -1
  13. package/script/helpers/Hook721DeploymentLib.sol +1 -1
  14. package/src/JB721TiersHook.sol +1 -1
  15. package/src/JB721TiersHookDeployer.sol +1 -1
  16. package/src/JB721TiersHookProjectDeployer.sol +1 -1
  17. package/src/JB721TiersHookStore.sol +12 -1
  18. package/src/abstract/ERC721.sol +1 -1
  19. package/src/abstract/JB721Hook.sol +3 -3
  20. package/src/libraries/JB721TiersHookLib.sol +17 -3
  21. package/test/721HookAttacks.t.sol +1 -1
  22. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +1 -1
  23. package/test/Fork.t.sol +1 -1
  24. package/test/TestAuditGaps.sol +1 -1
  25. package/test/TestSafeTransferReentrancy.t.sol +1 -1
  26. package/test/TestVotingUnitsLifecycle.t.sol +1 -1
  27. package/test/audit/AuditRegressions.t.sol +83 -0
  28. package/test/audit/CodexNemesis_CrossCurrencySplitNoPrices.t.sol +122 -0
  29. package/test/audit/USDTVoidReturnCompat.t.sol +301 -0
  30. package/test/fork/ERC20CashOutFork.t.sol +1 -1
  31. package/test/fork/ERC20TierSplitFork.t.sol +1 -1
  32. package/test/fork/IssueTokensForSplitsFork.t.sol +1 -1
  33. package/test/invariants/TierLifecycleInvariant.t.sol +1 -1
  34. package/test/invariants/TieredHookStoreInvariant.t.sol +1 -1
  35. package/test/invariants/handlers/TierLifecycleHandler.sol +1 -1
  36. package/test/invariants/handlers/TierStoreHandler.sol +1 -1
  37. package/test/regression/BrokenTerminalDoesNotDos.t.sol +1 -1
  38. package/test/regression/CacheTierLookup.t.sol +1 -1
  39. package/test/regression/ProjectDeployerRulesets.t.sol +1 -1
  40. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +1 -1
  41. package/test/regression/SplitDistributionBugs.t.sol +1 -1
  42. package/test/regression/SplitNoBeneficiary.t.sol +1 -1
  43. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +1 -1
  44. package/test/unit/JBBitmap.t.sol +1 -1
  45. package/test/unit/JBIpfsDecoder.t.sol +1 -1
  46. package/test/unit/TierSupplyReserveCheck.t.sol +1 -1
  47. package/test/unit/adjustTier_Unit.t.sol +1 -1
  48. package/test/unit/deployer_Unit.t.sol +1 -1
  49. package/test/unit/getters_constructor_Unit.t.sol +4 -1
  50. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +1 -1
  51. package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -1
  52. package/test/unit/pay_Unit.t.sol +1 -1
  53. package/test/unit/redeem_Unit.t.sol +1 -1
  54. package/test/unit/splitHookDistribution_Unit.t.sol +1 -1
  55. package/test/unit/tierSplitRouting_Unit.t.sol +1 -1
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
5
5
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
@@ -14,7 +14,6 @@ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBefor
14
14
  import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
15
15
  import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
16
16
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
17
- import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol";
18
17
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
19
18
 
20
19
  import {IJB721Hook} from "../interfaces/IJB721Hook.sol";
@@ -160,10 +159,11 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
160
159
  /// @notice Indicates if this contract adheres to the specified interface.
161
160
  /// @dev See {IERC165-supportsInterface}.
162
161
  /// @param interfaceId The ID of the interface to check for adherence to.
162
+ // ERC-2981 royalty support was removed — no royaltyInfo implementation exists.
163
163
  function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, IERC165) returns (bool) {
164
164
  return interfaceId == type(IJB721Hook).interfaceId || interfaceId == type(IJBRulesetDataHook).interfaceId
165
165
  || interfaceId == type(IJBPayHook).interfaceId || interfaceId == type(IJBCashOutHook).interfaceId
166
- || interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
166
+ || super.supportsInterface(interfaceId);
167
167
  }
168
168
 
169
169
  /// @notice Calculates the cumulative cash out weight of all NFT token IDs.
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
@@ -249,7 +249,9 @@ library JB721TiersHookLib {
249
249
  uint256 pricingCurrency = uint256(uint32(packedPricingContext));
250
250
  if (amountCurrency == pricingCurrency) return (totalSplitAmount, splitMetadata);
251
251
 
252
- if (address(prices) == address(0)) return (totalSplitAmount, splitMetadata);
252
+ // No price oracle available to convert between currencies. Return 0 to skip the split rather than
253
+ // forwarding an unconverted amount denominated in the wrong currency, which would over- or under-pay.
254
+ if (address(prices) == address(0)) return (0, splitMetadata);
253
255
 
254
256
  // forge-lint: disable-next-line(unsafe-typecast)
255
257
  uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
@@ -370,6 +372,12 @@ library JB721TiersHookLib {
370
372
  }
371
373
 
372
374
  /// @notice Distributes funds for a single tier's split group.
375
+ /// @dev Edge case: if both `_sendPayoutToSplit` returns false (reverting hook/terminal/beneficiary) AND the
376
+ /// subsequent `addToBalanceOf` call also reverts for the leftover amount, native ETH will remain stranded in the
377
+ /// hook contract with no recovery path. This requires two independent external call failures for the same split
378
+ /// payout and is a pre-existing documented edge case. ERC-20 tokens are not affected because failed
379
+ /// `addToBalanceOf` calls reset the approval. ERC-20 tokens remain in the hook contract. There is no built-in
380
+ /// recovery mechanism.
373
381
  function _distributeSingleSplit(
374
382
  IJBDirectory directory,
375
383
  IJBSplits splitsContract,
@@ -594,7 +602,13 @@ library JB721TiersHookLib {
594
602
  (bool success,) = split.beneficiary.call{value: amount}("");
595
603
  if (!success) return false;
596
604
  } else {
597
- SafeERC20.safeTransfer({token: IERC20(token), to: split.beneficiary, value: amount});
605
+ // Use the same low-level call + returndata check as SafeERC20.safeTransfer, but return
606
+ // false on failure instead of reverting. This handles non-standard tokens (e.g. USDT)
607
+ // that return void, while routing failed transfers to the project's balance instead
608
+ // of bricking all payments.
609
+ (bool callSuccess, bytes memory returndata) =
610
+ address(token).call(abi.encodeCall(IERC20.transfer, (split.beneficiary, amount)));
611
+ if (!callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)))) return false;
598
612
  }
599
613
  return true;
600
614
  }
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "./utils/UnitTestSetup.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
package/test/Fork.t.sol CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "./utils/UnitTestSetup.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "./utils/UnitTestSetup.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "./utils/UnitTestSetup.sol";
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ // Import the shared unit test setup which deploys a hook clone with 10 tiers.
5
+ // forge-lint: disable-next-line(unaliased-plain-import)
6
+ import "../utils/UnitTestSetup.sol";
7
+
8
+ // Import IERC2981 to compute its interface ID for the supportsInterface test.
9
+ import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol";
10
+
11
+ /// @notice Regression tests covering three audit findings for nana-721-hook-v6.
12
+ contract AuditRegressions is UnitTestSetup {
13
+ // -----------------------------------------------------------------------
14
+ // 1. Double-initialization guard
15
+ // -----------------------------------------------------------------------
16
+
17
+ /// @notice Calling initialize on an already-initialized clone must revert.
18
+ function test_doubleInitialization_reverts() public {
19
+ // The `hook` from setUp() is already initialized via the deployer.
20
+ // Expect a revert with AlreadyInitialized carrying the existing project ID.
21
+ vm.expectRevert(abi.encodeWithSelector(JB721TiersHook.JB721TiersHook_AlreadyInitialized.selector, projectId));
22
+
23
+ // Attempt to initialize the hook again with valid parameters — must revert.
24
+ hook.initialize(
25
+ projectId,
26
+ "AnotherName",
27
+ "AN",
28
+ baseUri,
29
+ IJB721TokenUriResolver(mockTokenUriResolver),
30
+ contractUri,
31
+ JB721InitTiersConfig({
32
+ tiers: new JB721TierConfig[](0), currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
33
+ }),
34
+ JB721TiersHookFlags({
35
+ preventOverspending: false,
36
+ issueTokensForSplits: false,
37
+ noNewTiersWithReserves: false,
38
+ noNewTiersWithVotes: false,
39
+ noNewTiersWithOwnerMinting: false
40
+ })
41
+ );
42
+ }
43
+
44
+ // -----------------------------------------------------------------------
45
+ // 2. recordSetDiscountPercentOf on a removed tier must revert
46
+ // -----------------------------------------------------------------------
47
+
48
+ /// @notice Setting the discount percent on a tier that has been removed must revert.
49
+ function test_setDiscountPercent_removedTier_reverts() public {
50
+ // The hook from setUp() has 10 tiers (IDs 1-10). Remove tier 1.
51
+ uint256[] memory tierIdsToRemove = new uint256[](1);
52
+
53
+ // Select tier 1 to remove.
54
+ tierIdsToRemove[0] = 1;
55
+
56
+ // Remove tier 1 as the hook owner.
57
+ vm.prank(owner);
58
+ hook.adjustTiers(new JB721TierConfig[](0), tierIdsToRemove);
59
+
60
+ // Expect a revert with TierRemoved when setting discount on the removed tier.
61
+ vm.expectRevert(abi.encodeWithSelector(JB721TiersHookStore.JB721TiersHookStore_TierRemoved.selector, 1));
62
+
63
+ // Attempt to set a discount on the removed tier — must revert.
64
+ vm.prank(owner);
65
+ hook.setDiscountPercentOf(1, 50);
66
+ }
67
+
68
+ // -----------------------------------------------------------------------
69
+ // 3. ERC-2981 supportsInterface returns false (support was removed)
70
+ // -----------------------------------------------------------------------
71
+
72
+ /// @notice supportsInterface must return false for IERC2981 since royalty support was removed.
73
+ function test_supportsInterface_erc2981_returnsFalse() public {
74
+ // Compute the IERC2981 interface ID from the imported interface.
75
+ bytes4 erc2981InterfaceId = type(IERC2981).interfaceId;
76
+
77
+ // Query supportsInterface on the hook.
78
+ bool supported = hook.supportsInterface(erc2981InterfaceId);
79
+
80
+ // Assert that ERC-2981 is NOT supported.
81
+ assertFalse(supported, "ERC-2981 must not be supported after royalty removal");
82
+ }
83
+ }
@@ -0,0 +1,122 @@
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 {IJB721TokenUriResolver} from "../../src/interfaces/IJB721TokenUriResolver.sol";
7
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
9
+ import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
10
+ import {IJBRulesets} from "@bananapus/core-v6/src/interfaces/IJBRulesets.sol";
11
+ import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
12
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
13
+
14
+ contract CodexNemesis_CrossCurrencySplitNoPrices is UnitTestSetup {
15
+ function test_crossCurrencySplit_withoutPrices_locksForwardedNativeFunds() public {
16
+ JB721TiersHook noPricesOrigin = new JB721TiersHook(
17
+ IJBDirectory(mockJBDirectory),
18
+ IJBPermissions(mockJBPermissions),
19
+ IJBPrices(address(0)),
20
+ IJBRulesets(mockJBRulesets),
21
+ store,
22
+ IJBSplits(mockJBSplits),
23
+ trustedForwarder
24
+ );
25
+
26
+ address noPricesProxy = makeAddr("noPricesProxy");
27
+ vm.etch(noPricesProxy, address(noPricesOrigin).code);
28
+ JB721TiersHook crossHook = JB721TiersHook(noPricesProxy);
29
+
30
+ (JB721TierConfig[] memory tierConfigs,) = _createTiers(defaultTierConfig, 1);
31
+ tierConfigs[0].price = 1 ether;
32
+ tierConfigs[0].splitPercent = 500_000_000; // 50%
33
+
34
+ crossHook.initialize(
35
+ projectId,
36
+ name,
37
+ symbol,
38
+ baseUri,
39
+ IJB721TokenUriResolver(mockTokenUriResolver),
40
+ contractUri,
41
+ JB721InitTiersConfig({tiers: tierConfigs, currency: uint32(USD()), decimals: 18}),
42
+ JB721TiersHookFlags({
43
+ preventOverspending: false,
44
+ issueTokensForSplits: false,
45
+ noNewTiersWithReserves: false,
46
+ noNewTiersWithVotes: false,
47
+ noNewTiersWithOwnerMinting: false
48
+ })
49
+ );
50
+
51
+ uint16[] memory tierIdsToMint = new uint16[](1);
52
+ tierIdsToMint[0] = 1;
53
+ bytes[] memory data = new bytes[](1);
54
+ data[0] = abi.encode(true, tierIdsToMint);
55
+ bytes4[] memory ids = new bytes4[](1);
56
+ ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
57
+ bytes memory payerMetadata = metadataHelper.createMetadata(ids, data);
58
+
59
+ (uint256 weight, JBPayHookSpecification[] memory hookSpecifications) = crossHook.beforePayRecordedWith(
60
+ JBBeforePayRecordedContext({
61
+ terminal: mockTerminalAddress,
62
+ payer: beneficiary,
63
+ amount: JBTokenAmount({
64
+ token: JBConstants.NATIVE_TOKEN,
65
+ value: 1 ether,
66
+ decimals: 18,
67
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
68
+ }),
69
+ projectId: projectId,
70
+ rulesetId: 0,
71
+ beneficiary: beneficiary,
72
+ weight: 10e18,
73
+ reservedPercent: 0,
74
+ metadata: payerMetadata
75
+ })
76
+ );
77
+
78
+ // When PRICES is address(0) and currencies differ, convertSplitAmounts returns 0
79
+ // to avoid forwarding an unconverted amount in the wrong currency denomination.
80
+ // This means weight is NOT reduced (full weight) and no funds are forwarded.
81
+ assertEq(weight, 10e18, "weight unchanged when split conversion fails due to missing prices");
82
+ assertEq(hookSpecifications.length, 1, "one pay hook spec");
83
+ assertEq(hookSpecifications[0].amount, 0, "split amount is zero when prices unavailable for conversion");
84
+
85
+ mockAndExpect(
86
+ address(mockJBDirectory),
87
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
88
+ abi.encode(true)
89
+ );
90
+
91
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
92
+ payer: beneficiary,
93
+ projectId: projectId,
94
+ rulesetId: 0,
95
+ amount: JBTokenAmount({
96
+ token: JBConstants.NATIVE_TOKEN,
97
+ value: 1 ether,
98
+ decimals: 18,
99
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
100
+ }),
101
+ forwardedAmount: JBTokenAmount({
102
+ token: JBConstants.NATIVE_TOKEN,
103
+ value: hookSpecifications[0].amount,
104
+ decimals: 18,
105
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
106
+ }),
107
+ weight: weight,
108
+ newlyIssuedTokenCount: 0,
109
+ beneficiary: beneficiary,
110
+ hookMetadata: hookSpecifications[0].metadata,
111
+ payerMetadata: payerMetadata
112
+ });
113
+
114
+ vm.deal(mockTerminalAddress, 1 ether);
115
+ vm.prank(mockTerminalAddress);
116
+ crossHook.afterPayRecordedWith{value: hookSpecifications[0].amount}(payContext);
117
+
118
+ assertEq(crossHook.balanceOf(beneficiary), 0, "no NFTs minted (currency mismatch, no prices)");
119
+ assertEq(crossHook.payCreditsOf(beneficiary), 0, "no credits accrued (currency mismatch, no prices)");
120
+ assertEq(address(crossHook).balance, 0, "no funds forwarded to hook when split conversion returns zero");
121
+ }
122
+ }
@@ -0,0 +1,301 @@
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
+ }
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/StdInvariant.sol";