@bannynet/core-v6 0.0.10 → 0.0.12
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/ADMINISTRATION.md +42 -31
- package/ARCHITECTURE.md +53 -13
- package/AUDIT_INSTRUCTIONS.md +68 -41
- package/CHANGE_LOG.md +28 -7
- package/README.md +72 -7
- package/RISKS.md +34 -8
- package/SKILLS.md +44 -3
- package/STYLE_GUIDE.md +1 -1
- package/USER_JOURNEYS.md +327 -325
- package/package.json +8 -8
- package/script/Add.Denver.s.sol +1 -1
- package/script/Deploy.s.sol +1 -1
- package/script/Drop1.s.sol +1 -1
- package/script/helpers/BannyverseDeploymentLib.sol +1 -1
- package/script/helpers/MigrationHelper.sol +1 -1
- package/src/Banny721TokenUriResolver.sol +148 -24
- package/test/Banny721TokenUriResolver.t.sol +1 -1
- package/test/BannyAttacks.t.sol +1 -1
- package/test/DecorateFlow.t.sol +32 -1
- package/test/Fork.t.sol +1 -1
- package/test/OutfitTransferLifecycle.t.sol +1 -1
- package/test/TestAuditGaps.sol +1 -1
- package/test/TestQALastMile.t.sol +1 -1
- package/test/audit/AntiStrandingRetention.t.sol +392 -0
- package/test/audit/MergedOutfitExclusivity.t.sol +223 -0
- package/test/audit/TryTransferFromStrandsAssets.t.sol +192 -0
- package/test/regression/ArrayLengthValidation.t.sol +1 -1
- package/test/regression/BodyCategoryValidation.t.sol +1 -1
- package/test/regression/BurnedTokenCheck.t.sol +1 -1
- package/test/regression/CEIReorder.t.sol +1 -1
- package/test/regression/ClearMetadata.t.sol +1 -1
- package/test/regression/MsgSenderEvents.t.sol +1 -1
- package/test/regression/RemovedTierDesync.t.sol +1 -1
- package/deployments/banny-core-v5/arbitrum/Banny721TokenUriResolver.json +0 -1809
- package/deployments/banny-core-v5/arbitrum_sepolia/Banny721TokenUriResolver.json +0 -1795
- package/deployments/banny-core-v5/base/Banny721TokenUriResolver.json +0 -1810
- package/deployments/banny-core-v5/base_sepolia/Banny721TokenUriResolver.json +0 -1796
- package/deployments/banny-core-v5/ethereum/Banny721TokenUriResolver.json +0 -1795
- package/deployments/banny-core-v5/optimism/Banny721TokenUriResolver.json +0 -1810
- package/deployments/banny-core-v5/optimism_sepolia/Banny721TokenUriResolver.json +0 -1796
- package/deployments/banny-core-v5/sepolia/Banny721TokenUriResolver.json +0 -1795
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bannynet/core-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
"artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'banny-core-v6'"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bananapus/721-hook-v6": "^0.0.
|
|
24
|
-
"@bananapus/core-v6": "^0.0.
|
|
25
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
26
|
-
"@bananapus/router-terminal-v6": "^0.0.
|
|
27
|
-
"@bananapus/suckers-v6": "^0.0.
|
|
28
|
-
"@croptop/core-v6": "^0.0.
|
|
23
|
+
"@bananapus/721-hook-v6": "^0.0.20",
|
|
24
|
+
"@bananapus/core-v6": "^0.0.26",
|
|
25
|
+
"@bananapus/permission-ids-v6": "^0.0.12",
|
|
26
|
+
"@bananapus/router-terminal-v6": "^0.0.19",
|
|
27
|
+
"@bananapus/suckers-v6": "^0.0.16",
|
|
28
|
+
"@croptop/core-v6": "^0.0.21",
|
|
29
29
|
"@openzeppelin/contracts": "^5.6.1",
|
|
30
|
-
"@rev-net/core-v6": "^0.0.
|
|
30
|
+
"@rev-net/core-v6": "^0.0.16",
|
|
31
31
|
"keccak": "^3.0.4"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
package/script/Add.Denver.s.sol
CHANGED
package/script/Deploy.s.sol
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity 0.8.26;
|
|
2
|
+
pragma solidity ^0.8.26;
|
|
3
3
|
|
|
4
4
|
import {Hook721Deployment, Hook721DeploymentLib} from "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
|
|
5
5
|
import {CoreDeployment, CoreDeploymentLib} from "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
|
package/script/Drop1.s.sol
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity 0.8.26;
|
|
2
|
+
pragma solidity ^0.8.26;
|
|
3
3
|
|
|
4
4
|
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
|
|
5
5
|
import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
|
|
@@ -1156,6 +1156,16 @@ contract Banny721TokenUriResolver is
|
|
|
1156
1156
|
// ---------------------- internal transactions ---------------------- //
|
|
1157
1157
|
//*********************************************************************//
|
|
1158
1158
|
|
|
1159
|
+
/// @notice Revert if an equipped asset is being reassigned away from a locked source body.
|
|
1160
|
+
/// @param hook The hook storing the assets.
|
|
1161
|
+
/// @param bannyBodyId The body currently using the asset.
|
|
1162
|
+
/// @param exemptBodyId The destination body currently being decorated.
|
|
1163
|
+
function _revertIfBodyLocked(address hook, uint256 bannyBodyId, uint256 exemptBodyId) internal view {
|
|
1164
|
+
if (bannyBodyId != 0 && bannyBodyId != exemptBodyId && outfitLockedUntil[hook][bannyBodyId] > block.timestamp) {
|
|
1165
|
+
revert Banny721TokenUriResolver_OutfitChangesLocked();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1159
1169
|
/// @notice Add a background to a banny body.
|
|
1160
1170
|
/// @param hook The hook storing the assets.
|
|
1161
1171
|
/// @param bannyBodyId The ID of the banny body being dressed.
|
|
@@ -1186,6 +1196,9 @@ contract Banny721TokenUriResolver is
|
|
|
1186
1196
|
if (_msgSender() != IERC721(hook).ownerOf(userId)) {
|
|
1187
1197
|
revert Banny721TokenUriResolver_UnauthorizedBackground();
|
|
1188
1198
|
}
|
|
1199
|
+
|
|
1200
|
+
// A locked source body keeps its equipped background until the lock expires.
|
|
1201
|
+
_revertIfBodyLocked({hook: hook, bannyBodyId: userId, exemptBodyId: bannyBodyId});
|
|
1189
1202
|
}
|
|
1190
1203
|
|
|
1191
1204
|
// Get the background's product info.
|
|
@@ -1196,26 +1209,39 @@ contract Banny721TokenUriResolver is
|
|
|
1196
1209
|
revert Banny721TokenUriResolver_UnrecognizedBackground();
|
|
1197
1210
|
}
|
|
1198
1211
|
|
|
1199
|
-
//
|
|
1200
|
-
|
|
1201
|
-
_userOf[hook][backgroundId] = bannyBodyId;
|
|
1202
|
-
|
|
1203
|
-
// Interactions: try-transfer the previous background back (may have been burned).
|
|
1212
|
+
// Try to transfer the previous background back before updating state.
|
|
1213
|
+
// If the transfer fails, the old background stays attached to prevent NFT stranding.
|
|
1204
1214
|
if (userOfPreviousBackground == bannyBodyId) {
|
|
1205
|
-
_tryTransferFrom({
|
|
1215
|
+
if (!_tryTransferFrom({
|
|
1216
|
+
hook: hook, from: address(this), to: _msgSender(), assetId: previousBackgroundId
|
|
1217
|
+
})) {
|
|
1218
|
+
// Transfer failed — skip the background change entirely so the old background
|
|
1219
|
+
// remains tracked and recoverable. The new background is not equipped.
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1206
1222
|
}
|
|
1207
1223
|
|
|
1224
|
+
// Effects: update state now that the old background has been successfully returned.
|
|
1225
|
+
_attachedBackgroundIdOf[hook][bannyBodyId] = backgroundId;
|
|
1226
|
+
_userOf[hook][backgroundId] = bannyBodyId;
|
|
1227
|
+
|
|
1208
1228
|
// Transfer the new background to this contract if it's not already owned by this contract.
|
|
1209
1229
|
if (owner != address(this)) {
|
|
1210
1230
|
_transferFrom({hook: hook, from: _msgSender(), to: address(this), assetId: backgroundId});
|
|
1211
1231
|
}
|
|
1212
1232
|
} else {
|
|
1213
|
-
//
|
|
1214
|
-
_attachedBackgroundIdOf[hook][bannyBodyId] = 0;
|
|
1215
|
-
|
|
1216
|
-
// Interactions: try-transfer the previous background back (may have been burned).
|
|
1233
|
+
// Try to transfer the previous background back before clearing state.
|
|
1217
1234
|
if (userOfPreviousBackground == bannyBodyId) {
|
|
1218
|
-
|
|
1235
|
+
// Only clear attachment state if the transfer succeeded. If it fails (e.g. recipient rejects
|
|
1236
|
+
// ERC-721), the background stays attached so the owner can retry or recover.
|
|
1237
|
+
if (_tryTransferFrom({
|
|
1238
|
+
hook: hook, from: address(this), to: _msgSender(), assetId: previousBackgroundId
|
|
1239
|
+
})) {
|
|
1240
|
+
_attachedBackgroundIdOf[hook][bannyBodyId] = 0;
|
|
1241
|
+
}
|
|
1242
|
+
} else {
|
|
1243
|
+
// No transfer needed — just clear the stale attachment record.
|
|
1244
|
+
_attachedBackgroundIdOf[hook][bannyBodyId] = 0;
|
|
1219
1245
|
}
|
|
1220
1246
|
}
|
|
1221
1247
|
}
|
|
@@ -1274,6 +1300,9 @@ contract Banny721TokenUriResolver is
|
|
|
1274
1300
|
if (_msgSender() != IERC721(hook).ownerOf(wearerId)) {
|
|
1275
1301
|
revert Banny721TokenUriResolver_UnauthorizedOutfit();
|
|
1276
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
// A locked source body keeps its equipped outfits until the lock expires.
|
|
1305
|
+
_revertIfBodyLocked({hook: hook, bannyBodyId: wearerId, exemptBodyId: bannyBodyId});
|
|
1277
1306
|
}
|
|
1278
1307
|
|
|
1279
1308
|
// Get the outfit's product info.
|
|
@@ -1319,7 +1348,17 @@ contract Banny721TokenUriResolver is
|
|
|
1319
1348
|
// decorated.
|
|
1320
1349
|
if (previousOutfitId != outfitId && wearerOf({hook: hook, outfitId: previousOutfitId}) == bannyBodyId) {
|
|
1321
1350
|
// Use try-transfer: the previous outfit may have been burned or its tier removed.
|
|
1322
|
-
|
|
1351
|
+
// If transfer fails, zero is NOT written to previousOutfitIds — the entry is preserved
|
|
1352
|
+
// so it can be retained in the attached list (preventing NFT stranding).
|
|
1353
|
+
if (_tryTransferFrom({
|
|
1354
|
+
hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId
|
|
1355
|
+
})) {
|
|
1356
|
+
// Mark as successfully transferred so it won't be retained.
|
|
1357
|
+
previousOutfitIds[previousOutfitIndex] = 0;
|
|
1358
|
+
}
|
|
1359
|
+
} else {
|
|
1360
|
+
// Not transferring (same outfit being re-equipped or not worn by this banny) — mark as handled.
|
|
1361
|
+
previousOutfitIds[previousOutfitIndex] = 0;
|
|
1323
1362
|
}
|
|
1324
1363
|
|
|
1325
1364
|
if (++previousOutfitIndex < previousOutfitIds.length) {
|
|
@@ -1358,10 +1397,17 @@ contract Banny721TokenUriResolver is
|
|
|
1358
1397
|
// decorated.
|
|
1359
1398
|
// Skip outfits that are being re-equipped in the new outfit set.
|
|
1360
1399
|
if (_isInArray(previousOutfitId, outfitIds)) {
|
|
1361
|
-
// This outfit is being re-equipped —
|
|
1400
|
+
// This outfit is being re-equipped — mark as handled.
|
|
1401
|
+
previousOutfitIds[previousOutfitIndex] = 0;
|
|
1362
1402
|
} else if (wearerOf({hook: hook, outfitId: previousOutfitId}) == bannyBodyId) {
|
|
1363
1403
|
// Use try-transfer: the previous outfit may have been burned or its tier removed.
|
|
1364
|
-
|
|
1404
|
+
// If transfer fails, the entry stays non-zero and is retained (preventing NFT stranding).
|
|
1405
|
+
if (_tryTransferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId})) {
|
|
1406
|
+
previousOutfitIds[previousOutfitIndex] = 0;
|
|
1407
|
+
}
|
|
1408
|
+
} else {
|
|
1409
|
+
// Not worn by this banny — mark as handled.
|
|
1410
|
+
previousOutfitIds[previousOutfitIndex] = 0;
|
|
1365
1411
|
}
|
|
1366
1412
|
|
|
1367
1413
|
if (++previousOutfitIndex < previousOutfitIds.length) {
|
|
@@ -1372,8 +1418,87 @@ contract Banny721TokenUriResolver is
|
|
|
1372
1418
|
}
|
|
1373
1419
|
}
|
|
1374
1420
|
|
|
1375
|
-
// Store the outfits.
|
|
1376
|
-
|
|
1421
|
+
// Store the outfits, merging in any retained outfits whose transfers failed.
|
|
1422
|
+
_storeOutfitsWithRetained({
|
|
1423
|
+
hook: hook, bannyBodyId: bannyBodyId, outfitIds: outfitIds, previousOutfitIds: previousOutfitIds
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/// @notice Store outfit IDs, merging in any retained outfits (failed transfers) from the previous list.
|
|
1428
|
+
/// @dev Entries in `previousOutfitIds` that are still non-zero represent outfits whose transfer back to the
|
|
1429
|
+
/// owner failed. These are appended to `outfitIds` so the attachment record is preserved and the owner can retry.
|
|
1430
|
+
/// @param hook The hook storing the assets.
|
|
1431
|
+
/// @param bannyBodyId The ID of the banny body being dressed.
|
|
1432
|
+
/// @param outfitIds The new outfit IDs to store.
|
|
1433
|
+
/// @param previousOutfitIds The previous outfit IDs array (zeroed entries were successfully transferred or
|
|
1434
|
+
/// handled).
|
|
1435
|
+
function _storeOutfitsWithRetained(
|
|
1436
|
+
address hook,
|
|
1437
|
+
uint256 bannyBodyId,
|
|
1438
|
+
uint256[] memory outfitIds,
|
|
1439
|
+
uint256[] memory previousOutfitIds
|
|
1440
|
+
)
|
|
1441
|
+
internal
|
|
1442
|
+
{
|
|
1443
|
+
// Count how many previous outfits failed to transfer (non-zero entries remain).
|
|
1444
|
+
uint256 retainedCount;
|
|
1445
|
+
for (uint256 i; i < previousOutfitIds.length; i++) {
|
|
1446
|
+
if (previousOutfitIds[i] != 0) retainedCount++;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (retainedCount == 0) {
|
|
1450
|
+
_attachedOutfitIdsOf[hook][bannyBodyId] = outfitIds;
|
|
1451
|
+
} else {
|
|
1452
|
+
// Merge new outfits with retained outfits that couldn't be transferred back.
|
|
1453
|
+
uint256[] memory mergedOutfitIds = new uint256[](outfitIds.length + retainedCount);
|
|
1454
|
+
for (uint256 i; i < outfitIds.length; i++) {
|
|
1455
|
+
mergedOutfitIds[i] = outfitIds[i];
|
|
1456
|
+
}
|
|
1457
|
+
uint256 mergeIndex = outfitIds.length;
|
|
1458
|
+
for (uint256 i; i < previousOutfitIds.length; i++) {
|
|
1459
|
+
if (previousOutfitIds[i] != 0) {
|
|
1460
|
+
mergedOutfitIds[mergeIndex++] = previousOutfitIds[i];
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Revalidate category exclusivity on the merged set. Retained outfits may conflict with the new outfits
|
|
1465
|
+
// (e.g., a retained HEAD outfit combined with new EYES/GLASSES/MOUTH/HEADTOP outfits).
|
|
1466
|
+
_validateCategoryExclusivity({hook: hook, outfitIds: mergedOutfitIds});
|
|
1467
|
+
|
|
1468
|
+
_attachedOutfitIdsOf[hook][bannyBodyId] = mergedOutfitIds;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/// @notice Validate that an array of outfit IDs does not violate category exclusivity rules.
|
|
1473
|
+
/// @dev HEAD is exclusive with EYES, GLASSES, MOUTH, and HEADTOP. SUIT is exclusive with SUIT_TOP and
|
|
1474
|
+
/// SUIT_BOTTOM. The array does not need to be sorted.
|
|
1475
|
+
/// @param hook The hook storing the assets.
|
|
1476
|
+
/// @param outfitIds The outfit IDs to validate.
|
|
1477
|
+
function _validateCategoryExclusivity(address hook, uint256[] memory outfitIds) internal view {
|
|
1478
|
+
bool hasHead;
|
|
1479
|
+
bool hasSuit;
|
|
1480
|
+
bool hasHeadAccessory;
|
|
1481
|
+
bool hasSuitPiece;
|
|
1482
|
+
|
|
1483
|
+
for (uint256 i; i < outfitIds.length; i++) {
|
|
1484
|
+
uint256 category = _productOfTokenId({hook: hook, tokenId: outfitIds[i]}).category;
|
|
1485
|
+
|
|
1486
|
+
if (category == _HEAD_CATEGORY) {
|
|
1487
|
+
hasHead = true;
|
|
1488
|
+
} else if (
|
|
1489
|
+
category == _EYES_CATEGORY || category == _GLASSES_CATEGORY || category == _MOUTH_CATEGORY
|
|
1490
|
+
|| category == _HEADTOP_CATEGORY
|
|
1491
|
+
) {
|
|
1492
|
+
hasHeadAccessory = true;
|
|
1493
|
+
} else if (category == _SUIT_CATEGORY) {
|
|
1494
|
+
hasSuit = true;
|
|
1495
|
+
} else if (category == _SUIT_TOP_CATEGORY || category == _SUIT_BOTTOM_CATEGORY) {
|
|
1496
|
+
hasSuitPiece = true;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (hasHead && hasHeadAccessory) revert Banny721TokenUriResolver_HeadAlreadyAdded();
|
|
1501
|
+
if (hasSuit && hasSuitPiece) revert Banny721TokenUriResolver_SuitAlreadyAdded();
|
|
1377
1502
|
}
|
|
1378
1503
|
|
|
1379
1504
|
/// @notice Check if a value is present in an array.
|
|
@@ -1395,18 +1520,17 @@ contract Banny721TokenUriResolver is
|
|
|
1395
1520
|
IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId});
|
|
1396
1521
|
}
|
|
1397
1522
|
|
|
1398
|
-
/// @notice Try to transfer a token,
|
|
1523
|
+
/// @notice Try to transfer a token, returning whether the transfer succeeded.
|
|
1399
1524
|
/// @dev Used when returning previously equipped items that may no longer exist.
|
|
1400
|
-
// `_tryTransferFrom` may silently fail to transfer outfit NFTs, leaving them attached to the
|
|
1401
|
-
// Banny but owned by a different address. This is by design — the try-catch pattern prevents a single failing
|
|
1402
|
-
// outfit transfer from blocking the entire Banny transfer. Orphaned outfits can be recovered by the original
|
|
1403
|
-
// owner.
|
|
1404
1525
|
/// @param hook The 721 contract of the token being transferred.
|
|
1405
1526
|
/// @param from The address to transfer the token from.
|
|
1406
1527
|
/// @param to The address to transfer the token to.
|
|
1407
1528
|
/// @param assetId The ID of the token to transfer.
|
|
1408
|
-
|
|
1529
|
+
/// @return success Whether the transfer succeeded.
|
|
1530
|
+
function _tryTransferFrom(address hook, address from, address to, uint256 assetId) internal returns (bool success) {
|
|
1409
1531
|
// slither-disable-next-line reentrancy-no-eth
|
|
1410
|
-
try IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId}) {
|
|
1532
|
+
try IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId}) {
|
|
1533
|
+
success = true;
|
|
1534
|
+
} catch {}
|
|
1411
1535
|
}
|
|
1412
1536
|
}
|
package/test/BannyAttacks.t.sol
CHANGED
package/test/DecorateFlow.t.sol
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity 0.8.26;
|
|
2
|
+
pragma solidity ^0.8.26;
|
|
3
3
|
|
|
4
4
|
import {Test} from "forge-std/Test.sol";
|
|
5
5
|
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
@@ -797,6 +797,37 @@ contract DecorateFlowTests is Test {
|
|
|
797
797
|
assertEq(hook.ownerOf(NECKLACE_1), bob, "necklace returned to bob");
|
|
798
798
|
}
|
|
799
799
|
|
|
800
|
+
/// @notice A locked body keeps its equipped background until the lock expires, even if the owner also controls an
|
|
801
|
+
/// unlocked destination body.
|
|
802
|
+
function test_lock_preventsMovingBackgroundFromLockedBody() public {
|
|
803
|
+
vm.prank(alice);
|
|
804
|
+
resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, new uint256[](0));
|
|
805
|
+
|
|
806
|
+
vm.prank(alice);
|
|
807
|
+
resolver.lockOutfitChangesFor(address(hook), BODY_A);
|
|
808
|
+
|
|
809
|
+
vm.prank(alice);
|
|
810
|
+
vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_OutfitChangesLocked.selector);
|
|
811
|
+
resolver.decorateBannyWith(address(hook), BODY_B, BACKGROUND_1, new uint256[](0));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/// @notice A locked body keeps its equipped outfits until the lock expires, even if the owner also controls an
|
|
815
|
+
/// unlocked destination body.
|
|
816
|
+
function test_lock_preventsMovingOutfitFromLockedBody() public {
|
|
817
|
+
uint256[] memory outfits = new uint256[](1);
|
|
818
|
+
outfits[0] = NECKLACE_1;
|
|
819
|
+
|
|
820
|
+
vm.prank(alice);
|
|
821
|
+
resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
|
|
822
|
+
|
|
823
|
+
vm.prank(alice);
|
|
824
|
+
resolver.lockOutfitChangesFor(address(hook), BODY_A);
|
|
825
|
+
|
|
826
|
+
vm.prank(alice);
|
|
827
|
+
vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_OutfitChangesLocked.selector);
|
|
828
|
+
resolver.decorateBannyWith(address(hook), BODY_B, 0, outfits);
|
|
829
|
+
}
|
|
830
|
+
|
|
800
831
|
// =========================================================================
|
|
801
832
|
// SECTION 8: Complex Multi-Step Scenarios
|
|
802
833
|
// =========================================================================
|
package/test/Fork.t.sol
CHANGED
package/test/TestAuditGaps.sol
CHANGED