@bananapus/721-hook-v6 0.0.1
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/.gas-snapshot +152 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/SKILLS.md +140 -0
- package/docs/book.css +13 -0
- package/docs/book.toml +12 -0
- package/docs/solidity.min.js +74 -0
- package/docs/src/README.md +253 -0
- package/docs/src/SUMMARY.md +38 -0
- package/docs/src/src/JB721TiersHook.sol/contract.JB721TiersHook.md +645 -0
- package/docs/src/src/JB721TiersHookDeployer.sol/contract.JB721TiersHookDeployer.md +99 -0
- package/docs/src/src/JB721TiersHookProjectDeployer.sol/contract.JB721TiersHookProjectDeployer.md +288 -0
- package/docs/src/src/JB721TiersHookStore.sol/contract.JB721TiersHookStore.md +1096 -0
- package/docs/src/src/README.md +11 -0
- package/docs/src/src/abstract/ERC721.sol/abstract.ERC721.md +430 -0
- package/docs/src/src/abstract/JB721Hook.sol/abstract.JB721Hook.md +309 -0
- package/docs/src/src/abstract/README.md +5 -0
- package/docs/src/src/interfaces/IJB721Hook.sol/interface.IJB721Hook.md +29 -0
- package/docs/src/src/interfaces/IJB721TiersHook.sol/interface.IJB721TiersHook.md +203 -0
- package/docs/src/src/interfaces/IJB721TiersHookDeployer.sol/interface.IJB721TiersHookDeployer.md +25 -0
- package/docs/src/src/interfaces/IJB721TiersHookProjectDeployer.sol/interface.IJB721TiersHookProjectDeployer.md +64 -0
- package/docs/src/src/interfaces/IJB721TiersHookStore.sol/interface.IJB721TiersHookStore.md +265 -0
- package/docs/src/src/interfaces/IJB721TokenUriResolver.sol/interface.IJB721TokenUriResolver.md +12 -0
- package/docs/src/src/interfaces/README.md +9 -0
- package/docs/src/src/libraries/JB721Constants.sol/library.JB721Constants.md +14 -0
- package/docs/src/src/libraries/JB721TiersRulesetMetadataResolver.sol/library.JB721TiersRulesetMetadataResolver.md +68 -0
- package/docs/src/src/libraries/JBBitmap.sol/library.JBBitmap.md +82 -0
- package/docs/src/src/libraries/JBIpfsDecoder.sol/library.JBIpfsDecoder.md +61 -0
- package/docs/src/src/libraries/README.md +7 -0
- package/docs/src/src/structs/JB721InitTiersConfig.sol/struct.JB721InitTiersConfig.md +27 -0
- package/docs/src/src/structs/JB721Tier.sol/struct.JB721Tier.md +59 -0
- package/docs/src/src/structs/JB721TierConfig.sol/struct.JB721TierConfig.md +60 -0
- package/docs/src/src/structs/JB721TiersHookFlags.sol/struct.JB721TiersHookFlags.md +26 -0
- package/docs/src/src/structs/JB721TiersMintReservesConfig.sol/struct.JB721TiersMintReservesConfig.md +16 -0
- package/docs/src/src/structs/JB721TiersRulesetMetadata.sol/struct.JB721TiersRulesetMetadata.md +20 -0
- package/docs/src/src/structs/JB721TiersSetDiscountPercentConfig.sol/struct.JB721TiersSetDiscountPercentConfig.md +16 -0
- package/docs/src/src/structs/JBBitmapWord.sol/struct.JBBitmapWord.md +19 -0
- package/docs/src/src/structs/JBDeploy721TiersHookConfig.sol/struct.JBDeploy721TiersHookConfig.md +34 -0
- package/docs/src/src/structs/JBLaunchProjectConfig.sol/struct.JBLaunchProjectConfig.md +23 -0
- package/docs/src/src/structs/JBLaunchRulesetsConfig.sol/struct.JBLaunchRulesetsConfig.md +22 -0
- package/docs/src/src/structs/JBPayDataHookRulesetConfig.sol/struct.JBPayDataHookRulesetConfig.md +51 -0
- package/docs/src/src/structs/JBPayDataHookRulesetMetadata.sol/struct.JBPayDataHookRulesetMetadata.md +66 -0
- package/docs/src/src/structs/JBQueueRulesetsConfig.sol/struct.JBQueueRulesetsConfig.md +21 -0
- package/docs/src/src/structs/JBStored721Tier.sol/struct.JBStored721Tier.md +42 -0
- package/docs/src/src/structs/README.md +18 -0
- package/foundry.lock +11 -0
- package/foundry.toml +22 -0
- package/package.json +31 -0
- package/remappings.txt +1 -0
- package/script/Deploy.s.sol +140 -0
- package/script/helpers/Hook721DeploymentLib.sol +81 -0
- package/slither-ci.config.json +10 -0
- package/sphinx.lock +476 -0
- package/src/JB721TiersHook.sol +765 -0
- package/src/JB721TiersHookDeployer.sol +114 -0
- package/src/JB721TiersHookProjectDeployer.sol +413 -0
- package/src/JB721TiersHookStore.sol +1195 -0
- package/src/abstract/ERC721.sol +484 -0
- package/src/abstract/JB721Hook.sol +279 -0
- package/src/interfaces/IJB721Hook.sol +21 -0
- package/src/interfaces/IJB721TiersHook.sol +135 -0
- package/src/interfaces/IJB721TiersHookDeployer.sol +22 -0
- package/src/interfaces/IJB721TiersHookProjectDeployer.sol +76 -0
- package/src/interfaces/IJB721TiersHookStore.sol +220 -0
- package/src/interfaces/IJB721TokenUriResolver.sol +10 -0
- package/src/libraries/JB721Constants.sol +7 -0
- package/src/libraries/JB721TiersRulesetMetadataResolver.sol +44 -0
- package/src/libraries/JBBitmap.sol +57 -0
- package/src/libraries/JBIpfsDecoder.sol +95 -0
- package/src/structs/JB721InitTiersConfig.sol +20 -0
- package/src/structs/JB721Tier.sol +39 -0
- package/src/structs/JB721TierConfig.sol +40 -0
- package/src/structs/JB721TiersHookFlags.sol +17 -0
- package/src/structs/JB721TiersMintReservesConfig.sol +9 -0
- package/src/structs/JB721TiersRulesetMetadata.sol +12 -0
- package/src/structs/JB721TiersSetDiscountPercentConfig.sol +9 -0
- package/src/structs/JBBitmapWord.sol +11 -0
- package/src/structs/JBDeploy721TiersHookConfig.sol +25 -0
- package/src/structs/JBLaunchProjectConfig.sol +18 -0
- package/src/structs/JBLaunchRulesetsConfig.sol +17 -0
- package/src/structs/JBPayDataHookRulesetConfig.sol +44 -0
- package/src/structs/JBPayDataHookRulesetMetadata.sol +46 -0
- package/src/structs/JBQueueRulesetsConfig.sol +13 -0
- package/src/structs/JBStored721Tier.sol +24 -0
- package/test/721HookAttacks.t.sol +396 -0
- package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +944 -0
- package/test/invariants/TierLifecycleInvariant.t.sol +187 -0
- package/test/invariants/TieredHookStoreInvariant.t.sol +81 -0
- package/test/invariants/handlers/TierLifecycleHandler.sol +262 -0
- package/test/invariants/handlers/TierStoreHandler.sol +155 -0
- package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +141 -0
- package/test/unit/JBBitmap.t.sol +169 -0
- package/test/unit/JBIpfsDecoder.t.sol +131 -0
- package/test/unit/M6_TierSupplyCheck.t.sol +220 -0
- package/test/unit/adjustTier_Unit.t.sol +1740 -0
- package/test/unit/deployer_Unit.t.sol +103 -0
- package/test/unit/getters_constructor_Unit.t.sol +548 -0
- package/test/unit/mintFor_mintReservesFor_Unit.t.sol +443 -0
- package/test/unit/pay_Unit.t.sol +1537 -0
- package/test/unit/redeem_Unit.t.sol +459 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.23;
|
|
3
|
+
|
|
4
|
+
import "forge-std/StdInvariant.sol";
|
|
5
|
+
import "../utils/UnitTestSetup.sol";
|
|
6
|
+
import "./handlers/TierLifecycleHandler.sol";
|
|
7
|
+
|
|
8
|
+
/// @title TierLifecycleInvariant
|
|
9
|
+
/// @notice State machine fuzzing for 721 tier lifecycle.
|
|
10
|
+
/// 6 invariants covering supply, cash out weight, credits, reserves, removal, and discounts.
|
|
11
|
+
contract TierLifecycleInvariant_Local is StdInvariant, UnitTestSetup {
|
|
12
|
+
TierLifecycleHandler public handler;
|
|
13
|
+
|
|
14
|
+
function setUp() public override {
|
|
15
|
+
super.setUp();
|
|
16
|
+
|
|
17
|
+
handler = new TierLifecycleHandler(hook, store, owner, mockJBController);
|
|
18
|
+
|
|
19
|
+
bytes4[] memory selectors = new bytes4[](10);
|
|
20
|
+
selectors[0] = TierLifecycleHandler.payAndMintNFT.selector;
|
|
21
|
+
selectors[1] = TierLifecycleHandler.cashOutNFT.selector;
|
|
22
|
+
selectors[2] = TierLifecycleHandler.addTier.selector;
|
|
23
|
+
selectors[3] = TierLifecycleHandler.removeTier.selector;
|
|
24
|
+
selectors[4] = TierLifecycleHandler.mintReserves.selector;
|
|
25
|
+
selectors[5] = TierLifecycleHandler.setDiscount.selector;
|
|
26
|
+
selectors[6] = TierLifecycleHandler.ownerMint.selector;
|
|
27
|
+
selectors[7] = TierLifecycleHandler.advanceTime.selector;
|
|
28
|
+
// Double-weight common operations
|
|
29
|
+
selectors[8] = TierLifecycleHandler.payAndMintNFT.selector;
|
|
30
|
+
selectors[9] = TierLifecycleHandler.payAndMintNFT.selector;
|
|
31
|
+
|
|
32
|
+
targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
|
|
33
|
+
targetContract(address(handler));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =========================================================================
|
|
37
|
+
// INV-721-1: Per-tier supply accounting
|
|
38
|
+
// =========================================================================
|
|
39
|
+
/// @notice For each active tier: remaining + minted == initialSupply.
|
|
40
|
+
/// minted = initialSupply - remaining (from store), should match ghost tracking.
|
|
41
|
+
function invariant_721_1_perTierSupplyAccounting() public {
|
|
42
|
+
uint256 maxTierId = store.maxTierIdOf(address(hook));
|
|
43
|
+
|
|
44
|
+
for (uint256 tierId = 1; tierId <= maxTierId; tierId++) {
|
|
45
|
+
// Get tier info
|
|
46
|
+
uint256[] memory categories = new uint256[](0);
|
|
47
|
+
JB721Tier[] memory allTiers = store.tiersOf(address(hook), categories, false, 0, 100);
|
|
48
|
+
|
|
49
|
+
for (uint256 i = 0; i < allTiers.length; i++) {
|
|
50
|
+
if (allTiers[i].id == tierId) {
|
|
51
|
+
uint256 initial = allTiers[i].initialSupply;
|
|
52
|
+
uint256 remaining = allTiers[i].remainingSupply;
|
|
53
|
+
uint256 burned = store.numberOfBurnedFor(address(hook), tierId);
|
|
54
|
+
|
|
55
|
+
// remaining + (minted including burned) should relate to initial
|
|
56
|
+
// minted = initial - remaining (total ever minted)
|
|
57
|
+
uint256 totalMinted = initial - remaining;
|
|
58
|
+
// totalMinted >= burned (can't burn more than minted)
|
|
59
|
+
assertGe(totalMinted, burned, "INV-721-1: Cannot burn more NFTs than were minted from tier");
|
|
60
|
+
|
|
61
|
+
// Outstanding = totalMinted - burned
|
|
62
|
+
uint256 outstanding = totalMinted - burned;
|
|
63
|
+
|
|
64
|
+
// Verify: remaining + outstanding + burned == initial
|
|
65
|
+
assertEq(remaining + outstanding + burned, initial, "INV-721-1: Supply accounting mismatch");
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// =========================================================================
|
|
73
|
+
// INV-721-2: Total cash out weight consistency
|
|
74
|
+
// =========================================================================
|
|
75
|
+
/// @notice totalCashOutWeight should equal sum(tier.price * outstanding) for all tiers.
|
|
76
|
+
function invariant_721_2_totalCashOutWeightConsistency() public {
|
|
77
|
+
uint256 totalWeight = store.totalCashOutWeight(address(hook));
|
|
78
|
+
|
|
79
|
+
uint256 maxTierId = store.maxTierIdOf(address(hook));
|
|
80
|
+
uint256 computedWeight = 0;
|
|
81
|
+
|
|
82
|
+
uint256[] memory categories = new uint256[](0);
|
|
83
|
+
JB721Tier[] memory allTiers = store.tiersOf(address(hook), categories, false, 0, 100);
|
|
84
|
+
|
|
85
|
+
for (uint256 i = 0; i < allTiers.length; i++) {
|
|
86
|
+
uint256 tierId = allTiers[i].id;
|
|
87
|
+
uint256 initial = allTiers[i].initialSupply;
|
|
88
|
+
uint256 remaining = allTiers[i].remainingSupply;
|
|
89
|
+
uint256 burned = store.numberOfBurnedFor(address(hook), tierId);
|
|
90
|
+
uint256 price = allTiers[i].price;
|
|
91
|
+
|
|
92
|
+
// Outstanding = minted - burned
|
|
93
|
+
uint256 minted = initial - remaining;
|
|
94
|
+
uint256 outstanding = minted > burned ? minted - burned : 0;
|
|
95
|
+
|
|
96
|
+
// Pending reserves are also counted in totalCashOutWeight
|
|
97
|
+
// (included in the store's calculation)
|
|
98
|
+
computedWeight += price * outstanding;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// totalWeight >= computedWeight (it also includes pending reserves)
|
|
102
|
+
assertGe(totalWeight, computedWeight, "INV-721-2: totalCashOutWeight must >= sum(price * outstanding)");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// =========================================================================
|
|
106
|
+
// INV-721-3: Pay credits non-negative
|
|
107
|
+
// =========================================================================
|
|
108
|
+
/// @notice Pay credits for each actor should be >= 0 (trivially true for uint,
|
|
109
|
+
/// but verifies no underflow/corruption).
|
|
110
|
+
function invariant_721_3_payCreditsNonNegative() public {
|
|
111
|
+
for (uint256 i = 0; i < handler.NUM_ACTORS(); i++) {
|
|
112
|
+
address actor = handler.getActor(i);
|
|
113
|
+
uint256 credits = hook.payCreditsOf(actor);
|
|
114
|
+
// uint256 is always >= 0, but this validates the slot isn't corrupted
|
|
115
|
+
assertGe(credits, 0, "INV-721-3: Pay credits should be non-negative");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// =========================================================================
|
|
120
|
+
// INV-721-4: Reserve mints bounded by frequency
|
|
121
|
+
// =========================================================================
|
|
122
|
+
/// @notice For each tier with reserve frequency > 0:
|
|
123
|
+
/// reservesMinted <= ceil(totalMinted / reserveFrequency).
|
|
124
|
+
function invariant_721_4_reserveMintsBounded() public {
|
|
125
|
+
uint256[] memory categories = new uint256[](0);
|
|
126
|
+
JB721Tier[] memory allTiers = store.tiersOf(address(hook), categories, false, 0, 100);
|
|
127
|
+
|
|
128
|
+
for (uint256 i = 0; i < allTiers.length; i++) {
|
|
129
|
+
uint256 tierId = allTiers[i].id;
|
|
130
|
+
uint16 reserveFreq = allTiers[i].reserveFrequency;
|
|
131
|
+
|
|
132
|
+
if (reserveFreq == 0) continue;
|
|
133
|
+
|
|
134
|
+
uint256 reservesMinted = store.numberOfReservesMintedFor(address(hook), tierId);
|
|
135
|
+
uint256 initial = allTiers[i].initialSupply;
|
|
136
|
+
uint256 remaining = allTiers[i].remainingSupply;
|
|
137
|
+
uint256 totalMinted = initial - remaining;
|
|
138
|
+
|
|
139
|
+
// Non-reserve mints = totalMinted - reservesMinted
|
|
140
|
+
uint256 nonReserveMints = totalMinted > reservesMinted ? totalMinted - reservesMinted : 0;
|
|
141
|
+
|
|
142
|
+
// Max allowed reserves = ceil(nonReserveMints / reserveFrequency)
|
|
143
|
+
uint256 maxReserves = 0;
|
|
144
|
+
if (nonReserveMints > 0) {
|
|
145
|
+
maxReserves = (nonReserveMints + reserveFreq - 1) / reserveFreq;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
assertLe(reservesMinted, maxReserves, "INV-721-4: Reserve mints exceed allowed maximum");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// =========================================================================
|
|
153
|
+
// INV-721-5: Removed tiers tracked correctly
|
|
154
|
+
// =========================================================================
|
|
155
|
+
/// @notice Removed tiers should not appear in tiersOf() listing.
|
|
156
|
+
function invariant_721_5_removedTiersExcluded() public {
|
|
157
|
+
uint256[] memory categories = new uint256[](0);
|
|
158
|
+
JB721Tier[] memory activeTiers = store.tiersOf(address(hook), categories, false, 0, 200);
|
|
159
|
+
|
|
160
|
+
for (uint256 i = 0; i < activeTiers.length; i++) {
|
|
161
|
+
// If handler tracked this tier as removed, it should NOT appear in active list
|
|
162
|
+
assertFalse(
|
|
163
|
+
handler.ghost_tierRemoved(activeTiers[i].id),
|
|
164
|
+
"INV-721-5: Removed tier should not appear in active tiers list"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// =========================================================================
|
|
170
|
+
// INV-721-6: Cash out weight bounded after discount
|
|
171
|
+
// =========================================================================
|
|
172
|
+
/// @notice After setDiscount, the per-token cash out weight should be <= original price.
|
|
173
|
+
function invariant_721_6_cashOutWeightBoundedByPrice() public {
|
|
174
|
+
uint256[] memory categories = new uint256[](0);
|
|
175
|
+
JB721Tier[] memory allTiers = store.tiersOf(address(hook), categories, false, 0, 100);
|
|
176
|
+
|
|
177
|
+
for (uint256 i = 0; i < allTiers.length; i++) {
|
|
178
|
+
uint256 tierId = allTiers[i].id;
|
|
179
|
+
uint256 price = allTiers[i].price;
|
|
180
|
+
|
|
181
|
+
// Cash out weight per token for this tier is just `price` (from the store)
|
|
182
|
+
// The discount only affects the mint price, not the cash out weight
|
|
183
|
+
// But verify the store returns a non-corrupt price
|
|
184
|
+
assertGt(price, 0, "INV-721-6: Tier price should be > 0 for active tiers");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.23;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {JB721TiersHookStore} from "../../src/JB721TiersHookStore.sol";
|
|
7
|
+
import {JB721TierConfig} from "../../src/structs/JB721TierConfig.sol";
|
|
8
|
+
import {JBStored721Tier} from "../../src/structs/JBStored721Tier.sol";
|
|
9
|
+
import {JB721TiersHookFlags} from "../../src/structs/JB721TiersHookFlags.sol";
|
|
10
|
+
import {JBBitmapWord} from "../../src/structs/JBBitmapWord.sol";
|
|
11
|
+
import {JB721Tier} from "../../src/structs/JB721Tier.sol";
|
|
12
|
+
import {TierStoreHandler} from "./handlers/TierStoreHandler.sol";
|
|
13
|
+
|
|
14
|
+
/// @notice Invariant tests for `JB721TiersHookStore` tier supply tracking.
|
|
15
|
+
contract TestTieredHookStoreInvariant is Test {
|
|
16
|
+
JB721TiersHookStore store;
|
|
17
|
+
TierStoreHandler handler;
|
|
18
|
+
|
|
19
|
+
function setUp() public {
|
|
20
|
+
store = new JB721TiersHookStore();
|
|
21
|
+
handler = new TierStoreHandler(store);
|
|
22
|
+
targetContract(address(handler));
|
|
23
|
+
|
|
24
|
+
// Exclude internal wrapper functions from fuzzing — they bypass handler safety checks.
|
|
25
|
+
bytes4[] memory selectors = new bytes4[](4);
|
|
26
|
+
selectors[0] = TierStoreHandler._doAddTiers.selector;
|
|
27
|
+
selectors[1] = TierStoreHandler._doRemoveTiers.selector;
|
|
28
|
+
selectors[2] = TierStoreHandler._doMint.selector;
|
|
29
|
+
selectors[3] = TierStoreHandler._doBurn.selector;
|
|
30
|
+
|
|
31
|
+
targetSelector(FuzzSelector({addr: address(handler), selectors: _handlerSelectors()}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _handlerSelectors() internal pure returns (bytes4[] memory selectors) {
|
|
35
|
+
selectors = new bytes4[](4);
|
|
36
|
+
selectors[0] = TierStoreHandler.addTier.selector;
|
|
37
|
+
selectors[1] = TierStoreHandler.removeTier.selector;
|
|
38
|
+
selectors[2] = TierStoreHandler.mint.selector;
|
|
39
|
+
selectors[3] = TierStoreHandler.burn.selector;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// @notice INV-721-1: For any tier, remaining + burned <= initial supply.
|
|
43
|
+
function invariant_supplyConservation() public {
|
|
44
|
+
address hook = handler.HOOK();
|
|
45
|
+
uint256 maxTier = store.maxTierIdOf(hook);
|
|
46
|
+
|
|
47
|
+
for (uint256 i = 1; i <= maxTier; i++) {
|
|
48
|
+
JB721Tier memory tier = store.tierOf(hook, i, false);
|
|
49
|
+
if (tier.initialSupply == 0) continue; // Tier doesn't exist.
|
|
50
|
+
|
|
51
|
+
uint256 burned = store.numberOfBurnedFor(hook, i);
|
|
52
|
+
// remaining + burned <= initial (remaining = initial - minted, but minted >= burned)
|
|
53
|
+
assertTrue(
|
|
54
|
+
tier.remainingSupply + burned <= tier.initialSupply, "remaining + burned must not exceed initial supply"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// @notice INV-721-2: Reserve mints never exceed the proportional reserve allocation.
|
|
60
|
+
function invariant_reserveMintBounds() public {
|
|
61
|
+
address hook = handler.HOOK();
|
|
62
|
+
uint256 maxTier = store.maxTierIdOf(hook);
|
|
63
|
+
|
|
64
|
+
for (uint256 i = 1; i <= maxTier; i++) {
|
|
65
|
+
JB721Tier memory tier = store.tierOf(hook, i, false);
|
|
66
|
+
if (tier.initialSupply == 0) continue;
|
|
67
|
+
|
|
68
|
+
uint256 reservesMinted = store.numberOfReservesMintedFor(hook, i);
|
|
69
|
+
if (tier.reserveFrequency == 0) {
|
|
70
|
+
assertEq(reservesMinted, 0, "no reserves without reserveFrequency");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// @notice INV-721-3: maxTierIdOf is monotonically increasing (never decreases).
|
|
76
|
+
function invariant_maxTierIdMonotonic() public {
|
|
77
|
+
address hook = handler.HOOK();
|
|
78
|
+
uint256 currentMax = store.maxTierIdOf(hook);
|
|
79
|
+
assertTrue(currentMax >= handler.lowestMaxTierIdSeen(), "maxTierIdOf should never decrease");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.23;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
import "../../../src/JB721TiersHook.sol";
|
|
6
|
+
import "../../../src/JB721TiersHookStore.sol";
|
|
7
|
+
import "../../../src/structs/JB721TierConfig.sol";
|
|
8
|
+
import "../../../src/structs/JB721Tier.sol";
|
|
9
|
+
import "../../../src/interfaces/IJB721TiersHookStore.sol";
|
|
10
|
+
import "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
11
|
+
import "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
|
|
12
|
+
import "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
|
|
13
|
+
|
|
14
|
+
/// @title TierLifecycleHandler
|
|
15
|
+
/// @notice Handler for 721 tier lifecycle invariant testing.
|
|
16
|
+
/// 8 operations: payAndMintNFT, cashOutNFT, addTier, removeTier,
|
|
17
|
+
/// mintReserves, setDiscount, ownerMint, advanceTime.
|
|
18
|
+
contract TierLifecycleHandler is Test {
|
|
19
|
+
JB721TiersHook public hook;
|
|
20
|
+
JB721TiersHookStore public store;
|
|
21
|
+
address public hookAddress;
|
|
22
|
+
address public owner;
|
|
23
|
+
address public mockController;
|
|
24
|
+
|
|
25
|
+
uint256 public constant PROJECT_ID = 69;
|
|
26
|
+
uint256 public constant NUM_ACTORS = 5;
|
|
27
|
+
address[] public actors;
|
|
28
|
+
|
|
29
|
+
// Ghost variables for supply tracking
|
|
30
|
+
mapping(uint256 => uint256) public ghost_mintedPerTier; // tierId => minted count
|
|
31
|
+
mapping(uint256 => uint256) public ghost_burnedPerTier; // tierId => burned count
|
|
32
|
+
mapping(uint256 => uint256) public ghost_reservesMintedPerTier; // tierId => reserves minted
|
|
33
|
+
uint256 public ghost_totalPayCredits;
|
|
34
|
+
mapping(address => uint256) public ghost_actorCredits; // actor => credit balance
|
|
35
|
+
uint256 public ghost_tiersAdded;
|
|
36
|
+
uint256 public ghost_tiersRemoved;
|
|
37
|
+
|
|
38
|
+
// Track token IDs per actor for cash outs
|
|
39
|
+
mapping(address => uint256[]) internal _actorTokenIds;
|
|
40
|
+
|
|
41
|
+
// Track removed tier IDs
|
|
42
|
+
mapping(uint256 => bool) public ghost_tierRemoved;
|
|
43
|
+
|
|
44
|
+
// Operation counters
|
|
45
|
+
uint256 public callCount_payAndMint;
|
|
46
|
+
uint256 public callCount_cashOut;
|
|
47
|
+
uint256 public callCount_addTier;
|
|
48
|
+
uint256 public callCount_removeTier;
|
|
49
|
+
uint256 public callCount_mintReserves;
|
|
50
|
+
uint256 public callCount_setDiscount;
|
|
51
|
+
uint256 public callCount_ownerMint;
|
|
52
|
+
uint256 public callCount_advanceTime;
|
|
53
|
+
|
|
54
|
+
constructor(JB721TiersHook _hook, JB721TiersHookStore _store, address _owner, address _mockController) {
|
|
55
|
+
hook = _hook;
|
|
56
|
+
store = _store;
|
|
57
|
+
hookAddress = address(_hook);
|
|
58
|
+
owner = _owner;
|
|
59
|
+
mockController = _mockController;
|
|
60
|
+
|
|
61
|
+
for (uint256 i = 0; i < NUM_ACTORS; i++) {
|
|
62
|
+
address actor = address(uint160(0x6000 + i));
|
|
63
|
+
actors.push(actor);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _getActor(uint256 seed) internal view returns (address) {
|
|
68
|
+
return actors[seed % actors.length];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// @notice Simulate a payment that mints NFTs by directly calling store.recordMint.
|
|
72
|
+
function payAndMintNFT(uint256 seed) external {
|
|
73
|
+
address actor = _getActor(seed);
|
|
74
|
+
|
|
75
|
+
// Get max tier ID to pick a valid tier
|
|
76
|
+
uint256 maxTierId = store.maxTierIdOf(hookAddress);
|
|
77
|
+
if (maxTierId == 0) return;
|
|
78
|
+
|
|
79
|
+
// Pick a tier (1-indexed)
|
|
80
|
+
uint256 tierId = (seed % maxTierId) + 1;
|
|
81
|
+
|
|
82
|
+
// Check if tier is removed
|
|
83
|
+
if (ghost_tierRemoved[tierId]) return;
|
|
84
|
+
|
|
85
|
+
// Get tier info
|
|
86
|
+
uint256[] memory categories = new uint256[](0);
|
|
87
|
+
JB721Tier[] memory tierInfo = store.tiersOf(hookAddress, categories, false, 0, 100);
|
|
88
|
+
|
|
89
|
+
// Find the target tier
|
|
90
|
+
uint104 tierPrice = 0;
|
|
91
|
+
for (uint256 i = 0; i < tierInfo.length; i++) {
|
|
92
|
+
if (tierInfo[i].id == tierId) {
|
|
93
|
+
tierPrice = tierInfo[i].price;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (tierPrice == 0) return;
|
|
98
|
+
|
|
99
|
+
// Call recordMint as the hook
|
|
100
|
+
uint16[] memory tierIds = new uint16[](1);
|
|
101
|
+
tierIds[0] = uint16(tierId);
|
|
102
|
+
|
|
103
|
+
vm.prank(hookAddress);
|
|
104
|
+
try store.recordMint(tierPrice, tierIds, false) returns (uint256[] memory tokenIds, uint256) {
|
|
105
|
+
// Track minted tokens
|
|
106
|
+
for (uint256 i = 0; i < tokenIds.length; i++) {
|
|
107
|
+
_actorTokenIds[actor].push(tokenIds[i]);
|
|
108
|
+
}
|
|
109
|
+
ghost_mintedPerTier[tierId]++;
|
|
110
|
+
callCount_payAndMint++;
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// @notice Simulate a cash out by burning an NFT.
|
|
115
|
+
function cashOutNFT(uint256 seed) external {
|
|
116
|
+
address actor = _getActor(seed);
|
|
117
|
+
|
|
118
|
+
// Check if actor has any tokens
|
|
119
|
+
if (_actorTokenIds[actor].length == 0) return;
|
|
120
|
+
|
|
121
|
+
// Pick a token to burn
|
|
122
|
+
uint256 tokenIndex = seed % _actorTokenIds[actor].length;
|
|
123
|
+
uint256 tokenId = _actorTokenIds[actor][tokenIndex];
|
|
124
|
+
|
|
125
|
+
uint256 tierId = store.tierIdOfToken(tokenId);
|
|
126
|
+
|
|
127
|
+
// Call recordBurn as the hook
|
|
128
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
129
|
+
tokenIds[0] = tokenId;
|
|
130
|
+
|
|
131
|
+
vm.prank(hookAddress);
|
|
132
|
+
try store.recordBurn(tokenIds) {
|
|
133
|
+
ghost_burnedPerTier[tierId]++;
|
|
134
|
+
|
|
135
|
+
// Remove token from actor's list (swap and pop)
|
|
136
|
+
uint256 lastIndex = _actorTokenIds[actor].length - 1;
|
|
137
|
+
if (tokenIndex != lastIndex) {
|
|
138
|
+
_actorTokenIds[actor][tokenIndex] = _actorTokenIds[actor][lastIndex];
|
|
139
|
+
}
|
|
140
|
+
_actorTokenIds[actor].pop();
|
|
141
|
+
|
|
142
|
+
callCount_cashOut++;
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// @notice Add a new tier.
|
|
147
|
+
function addTier(uint256 seed) external {
|
|
148
|
+
uint256 price = bound(seed, 1, 1000);
|
|
149
|
+
uint256 supply = bound(seed >> 8, 10, 500);
|
|
150
|
+
|
|
151
|
+
JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
|
|
152
|
+
newTiers[0] = JB721TierConfig({
|
|
153
|
+
price: uint104(price),
|
|
154
|
+
initialSupply: uint32(supply),
|
|
155
|
+
votingUnits: 0,
|
|
156
|
+
reserveFrequency: 0,
|
|
157
|
+
reserveBeneficiary: address(0),
|
|
158
|
+
encodedIPFSUri: bytes32(0),
|
|
159
|
+
category: uint24(100),
|
|
160
|
+
discountPercent: 0,
|
|
161
|
+
allowOwnerMint: true,
|
|
162
|
+
useReserveBeneficiaryAsDefault: false,
|
|
163
|
+
transfersPausable: false,
|
|
164
|
+
useVotingUnits: false,
|
|
165
|
+
cannotBeRemoved: false,
|
|
166
|
+
cannotIncreaseDiscountPercent: false
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
vm.prank(hookAddress);
|
|
170
|
+
try store.recordAddTiers(newTiers) returns (uint256[] memory tierIds) {
|
|
171
|
+
ghost_tiersAdded += tierIds.length;
|
|
172
|
+
callCount_addTier++;
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// @notice Remove a tier.
|
|
177
|
+
function removeTier(uint256 seed) external {
|
|
178
|
+
uint256 maxTierId = store.maxTierIdOf(hookAddress);
|
|
179
|
+
if (maxTierId == 0) return;
|
|
180
|
+
|
|
181
|
+
uint256 tierId = (seed % maxTierId) + 1;
|
|
182
|
+
if (ghost_tierRemoved[tierId]) return;
|
|
183
|
+
|
|
184
|
+
uint256[] memory tierIds = new uint256[](1);
|
|
185
|
+
tierIds[0] = tierId;
|
|
186
|
+
|
|
187
|
+
vm.prank(hookAddress);
|
|
188
|
+
try store.recordRemoveTierIds(tierIds) {
|
|
189
|
+
ghost_tierRemoved[tierId] = true;
|
|
190
|
+
ghost_tiersRemoved++;
|
|
191
|
+
callCount_removeTier++;
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// @notice Mint reserves for a tier.
|
|
196
|
+
function mintReserves(uint256 seed) external {
|
|
197
|
+
uint256 maxTierId = store.maxTierIdOf(hookAddress);
|
|
198
|
+
if (maxTierId == 0) return;
|
|
199
|
+
|
|
200
|
+
uint256 tierId = (seed % maxTierId) + 1;
|
|
201
|
+
if (ghost_tierRemoved[tierId]) return;
|
|
202
|
+
|
|
203
|
+
// Try to mint 1 reserve
|
|
204
|
+
vm.prank(hookAddress);
|
|
205
|
+
try store.recordMintReservesFor(tierId, 1) returns (uint256[] memory tokenIds) {
|
|
206
|
+
ghost_reservesMintedPerTier[tierId] += tokenIds.length;
|
|
207
|
+
callCount_mintReserves++;
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// @notice Set discount for a tier.
|
|
212
|
+
function setDiscount(uint256 seed) external {
|
|
213
|
+
uint256 maxTierId = store.maxTierIdOf(hookAddress);
|
|
214
|
+
if (maxTierId == 0) return;
|
|
215
|
+
|
|
216
|
+
uint256 tierId = (seed % maxTierId) + 1;
|
|
217
|
+
uint256 discount = bound(seed >> 8, 0, 100); // 0-100%
|
|
218
|
+
|
|
219
|
+
vm.prank(hookAddress);
|
|
220
|
+
try store.recordSetDiscountPercentOf(tierId, discount) {
|
|
221
|
+
callCount_setDiscount++;
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// @notice Owner mint (direct mint with isOwnerMint=true).
|
|
226
|
+
function ownerMint(uint256 seed) external {
|
|
227
|
+
uint256 maxTierId = store.maxTierIdOf(hookAddress);
|
|
228
|
+
if (maxTierId == 0) return;
|
|
229
|
+
|
|
230
|
+
uint256 tierId = (seed % maxTierId) + 1;
|
|
231
|
+
if (ghost_tierRemoved[tierId]) return;
|
|
232
|
+
|
|
233
|
+
uint16[] memory tierIds = new uint16[](1);
|
|
234
|
+
tierIds[0] = uint16(tierId);
|
|
235
|
+
|
|
236
|
+
vm.prank(hookAddress);
|
|
237
|
+
try store.recordMint(0, tierIds, true) returns (uint256[] memory tokenIds, uint256) {
|
|
238
|
+
address actor = _getActor(seed);
|
|
239
|
+
for (uint256 i = 0; i < tokenIds.length; i++) {
|
|
240
|
+
_actorTokenIds[actor].push(tokenIds[i]);
|
|
241
|
+
}
|
|
242
|
+
ghost_mintedPerTier[tierId]++;
|
|
243
|
+
callCount_ownerMint++;
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// @notice Advance time.
|
|
248
|
+
function advanceTime(uint256 seed) external {
|
|
249
|
+
uint256 delta = bound(seed, 1 hours, 30 days);
|
|
250
|
+
vm.warp(block.timestamp + delta);
|
|
251
|
+
callCount_advanceTime++;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// View helpers
|
|
255
|
+
function getActor(uint256 index) external view returns (address) {
|
|
256
|
+
return actors[index];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getActorTokenCount(address actor) external view returns (uint256) {
|
|
260
|
+
return _actorTokenIds[actor].length;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.23;
|
|
3
|
+
|
|
4
|
+
import {CommonBase} from "forge-std/Base.sol";
|
|
5
|
+
import {StdCheats} from "forge-std/StdCheats.sol";
|
|
6
|
+
import {StdUtils} from "forge-std/StdUtils.sol";
|
|
7
|
+
|
|
8
|
+
import {JB721TiersHookStore} from "../../../src/JB721TiersHookStore.sol";
|
|
9
|
+
import {JB721TierConfig} from "../../../src/structs/JB721TierConfig.sol";
|
|
10
|
+
import {JB721TiersHookFlags} from "../../../src/structs/JB721TiersHookFlags.sol";
|
|
11
|
+
|
|
12
|
+
/// @notice Handler for JB721TiersHookStore invariant tests.
|
|
13
|
+
/// @dev Acts as the "hook" address itself, so msg.sender (this) == hook in the store.
|
|
14
|
+
contract TierStoreHandler is CommonBase, StdCheats, StdUtils {
|
|
15
|
+
JB721TiersHookStore public immutable STORE;
|
|
16
|
+
|
|
17
|
+
// This contract acts as the hook.
|
|
18
|
+
address public HOOK;
|
|
19
|
+
|
|
20
|
+
// Ghost variable tracking the max tier ID seen.
|
|
21
|
+
uint256 public lowestMaxTierIdSeen;
|
|
22
|
+
|
|
23
|
+
// Track how many tiers we've added.
|
|
24
|
+
uint256 public tiersAdded;
|
|
25
|
+
|
|
26
|
+
// Track minted token IDs so we can only burn minted tokens.
|
|
27
|
+
uint256[] public mintedTokenIds;
|
|
28
|
+
|
|
29
|
+
// Track burned token IDs to avoid double-burn.
|
|
30
|
+
mapping(uint256 => bool) public wasBurned;
|
|
31
|
+
|
|
32
|
+
constructor(JB721TiersHookStore store) {
|
|
33
|
+
STORE = store;
|
|
34
|
+
HOOK = address(this);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// @notice Add a new tier.
|
|
38
|
+
function addTier(uint104 price, uint32 initialSupply, uint16 reserveFrequency, uint24 category) public {
|
|
39
|
+
// Bound inputs to valid ranges.
|
|
40
|
+
initialSupply = uint32(bound(initialSupply, 1, 1_000_000));
|
|
41
|
+
price = uint104(bound(price, 1, type(uint104).max));
|
|
42
|
+
category = uint24(bound(category, 0, 100));
|
|
43
|
+
|
|
44
|
+
// Reserve frequency must be <= initialSupply if non-zero.
|
|
45
|
+
if (reserveFrequency > 0) {
|
|
46
|
+
reserveFrequency = uint16(bound(reserveFrequency, 1, 200));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
JB721TierConfig[] memory configs = new JB721TierConfig[](1);
|
|
50
|
+
configs[0] = JB721TierConfig({
|
|
51
|
+
price: price,
|
|
52
|
+
initialSupply: initialSupply,
|
|
53
|
+
votingUnits: 0,
|
|
54
|
+
reserveFrequency: reserveFrequency,
|
|
55
|
+
reserveBeneficiary: address(0),
|
|
56
|
+
encodedIPFSUri: bytes32(0),
|
|
57
|
+
category: category,
|
|
58
|
+
discountPercent: 0,
|
|
59
|
+
allowOwnerMint: true,
|
|
60
|
+
useReserveBeneficiaryAsDefault: false,
|
|
61
|
+
transfersPausable: false,
|
|
62
|
+
useVotingUnits: false,
|
|
63
|
+
cannotBeRemoved: false,
|
|
64
|
+
cannotIncreaseDiscountPercent: false
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
try this._doAddTiers(configs) {
|
|
68
|
+
tiersAdded++;
|
|
69
|
+
_updateMaxTierIdSeen();
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// @dev External wrapper so calldata encoding is correct for the store.
|
|
74
|
+
function _doAddTiers(JB721TierConfig[] calldata configs) external {
|
|
75
|
+
STORE.recordAddTiers(configs);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// @notice Remove a tier.
|
|
79
|
+
function removeTier(uint256 tierId) public {
|
|
80
|
+
uint256 maxId = STORE.maxTierIdOf(HOOK);
|
|
81
|
+
if (maxId == 0) return;
|
|
82
|
+
|
|
83
|
+
tierId = bound(tierId, 1, maxId);
|
|
84
|
+
|
|
85
|
+
uint256[] memory ids = new uint256[](1);
|
|
86
|
+
ids[0] = tierId;
|
|
87
|
+
|
|
88
|
+
try this._doRemoveTiers(ids) {} catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// @dev External wrapper for calldata.
|
|
92
|
+
function _doRemoveTiers(uint256[] calldata ids) external {
|
|
93
|
+
STORE.recordRemoveTierIds(ids);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// @notice Mint from a tier (simulates store recording a mint).
|
|
97
|
+
function mint(uint256 tierId) public {
|
|
98
|
+
uint256 maxId = STORE.maxTierIdOf(HOOK);
|
|
99
|
+
if (maxId == 0) return;
|
|
100
|
+
|
|
101
|
+
tierId = bound(tierId, 1, maxId);
|
|
102
|
+
|
|
103
|
+
uint16[] memory tierIds = new uint16[](1);
|
|
104
|
+
tierIds[0] = uint16(tierId);
|
|
105
|
+
|
|
106
|
+
try this._doMint(type(uint256).max, tierIds) returns (uint256[] memory tokenIds) {
|
|
107
|
+
for (uint256 i; i < tokenIds.length; i++) {
|
|
108
|
+
if (tokenIds[i] != 0) {
|
|
109
|
+
mintedTokenIds.push(tokenIds[i]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {}
|
|
113
|
+
|
|
114
|
+
_updateMaxTierIdSeen();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// @dev External wrapper for calldata.
|
|
118
|
+
function _doMint(uint256 amount, uint16[] calldata tierIds) external returns (uint256[] memory tokenIds) {
|
|
119
|
+
(tokenIds,) = STORE.recordMint(amount, tierIds, true);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// @notice Burn a minted token.
|
|
123
|
+
function burn(uint256 indexSeed) public {
|
|
124
|
+
if (mintedTokenIds.length == 0) return;
|
|
125
|
+
|
|
126
|
+
uint256 index = bound(indexSeed, 0, mintedTokenIds.length - 1);
|
|
127
|
+
uint256 tokenId = mintedTokenIds[index];
|
|
128
|
+
|
|
129
|
+
// Skip if already burned.
|
|
130
|
+
if (wasBurned[tokenId]) return;
|
|
131
|
+
|
|
132
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
133
|
+
tokenIds[0] = tokenId;
|
|
134
|
+
|
|
135
|
+
try this._doBurn(tokenIds) {
|
|
136
|
+
wasBurned[tokenId] = true;
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// @dev External wrapper for calldata.
|
|
141
|
+
function _doBurn(uint256[] calldata tokenIds) external {
|
|
142
|
+
STORE.recordBurn(tokenIds);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mintedTokenCount() external view returns (uint256) {
|
|
146
|
+
return mintedTokenIds.length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _updateMaxTierIdSeen() internal {
|
|
150
|
+
uint256 current = STORE.maxTierIdOf(HOOK);
|
|
151
|
+
if (lowestMaxTierIdSeen == 0 || current > lowestMaxTierIdSeen) {
|
|
152
|
+
lowestMaxTierIdSeen = current;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|