diff --git a/script/BaseScript.s.sol b/script/BaseScript.s.sol index 0468ec6..c5981d9 100644 --- a/script/BaseScript.s.sol +++ b/script/BaseScript.s.sol @@ -46,6 +46,18 @@ contract BaseScript is Script { } } + /** + * @notice Overrides the deploy mode read from IS_TEST / IS_TESTNET. + * @dev Also callable from tests to select the deploy mode without touching process-global env vars, + * which forge shares across suites running in parallel. + * @param isTest_ Whether the deploy is a unit-test deploy + * @param isTestnet_ Whether the deploy targets a testnet + */ + function setDeployMode(bool isTest_, bool isTestnet_) public { + isTest = isTest_; + isTestnet = isTestnet_; + } + function getAdmins() public { uint256 adminLength = vm.envUint("ADMIN_LENGTH"); for (uint256 i = 0; i < adminLength; i++) { diff --git a/script/DeployAll.s.sol b/script/DeployAll.s.sol index 577a841..2390f5d 100644 --- a/script/DeployAll.s.sol +++ b/script/DeployAll.s.sol @@ -33,8 +33,10 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan deployForfeitedAssetsPool(); // Deploy SubConsols deploySubConsols(); - // Deploy YieldStrategies - deployYieldStrategies(); // Disabled for production + // Deploy YieldStrategies (mocks; production SubConsols launch without one) + if (isTest || isTestnet) { + deployYieldStrategies(); + } // Deploy Consol deployConsol(); // Get or create the pyth oracle (skipped when the deploy prices collateral with Chainlink feeds) @@ -45,8 +47,8 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan deployInterestOracle(); // Deploy PriceOracles that read from the PythOracle deployPriceOracles(); - // Deploy NFTMetadataGenerator - deployNFTMetadataGenerator(); // Disabled for production + // Deploy NFTMetadataGenerator (UUPS proxy) + deployNFTMetadataGenerator(); // Deploy GeneralManager deployGeneralManager(); // Configure the origination fee @@ -117,6 +119,7 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan assertContractRoleInvariants(address(orderPool), "OrderPool"); assertContractRoleInvariants(address(generalManager), "GeneralManager"); assertContractRoleInvariants(address(originationPoolScheduler), "OriginationPoolScheduler"); + assertContractRoleInvariants(address(nftMetadataGenerator), "NFTMetadataGenerator"); } /** @@ -141,8 +144,12 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan function getPath() public view returns (string memory path) { uint256 chainId = block.chainid; string memory root = vm.projectRoot(); + // Only the unit tests set a suffix, so a real deploy always writes the chain file. // Explicitly excluding the localHost test to keep it in sync with local anvil deploys - if (isTest && keccak256(bytes(addressesFileSuffix)) != keccak256(bytes("LocalhostSetupTest"))) { + if ( + bytes(addressesFileSuffix).length > 0 + && keccak256(bytes(addressesFileSuffix)) != keccak256(bytes("LocalhostSetupTest")) + ) { path = string.concat(root, "/addresses/tests/addresses-", addressesFileSuffix, ".json"); } else { path = string.concat(root, "/addresses/addresses-", vm.toString(chainId), ".json"); diff --git a/script/DeployNFTMetadataGenerator.s.sol b/script/DeployNFTMetadataGenerator.s.sol index 06400fa..0395bf3 100644 --- a/script/DeployNFTMetadataGenerator.s.sol +++ b/script/DeployNFTMetadataGenerator.s.sol @@ -3,9 +3,12 @@ pragma solidity ^0.8.20; import {BaseScript} from "./BaseScript.s.sol"; import {INFTMetadataGenerator} from "../src/interfaces/INFTMetadataGenerator.sol"; -import {MockNFTMetadataGenerator} from "../test/mocks/MockNFTMetadataGenerator.sol"; +import {NFTMetadataGenerator} from "../src/NFTMetadataGenerator.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Roles} from "../src/libraries/Roles.sol"; contract DeployNFTMetadataGenerator is BaseScript { + NFTMetadataGenerator public nftMetadataGeneratorImplementation; INFTMetadataGenerator public nftMetadataGenerator; function setUp() public virtual override { @@ -19,12 +22,23 @@ contract DeployNFTMetadataGenerator is BaseScript { vm.stopBroadcast(); } + /** + * @notice Deploys the NFT metadata generator the LoanManager's MortgageNFT will point at. + * @dev The pointer is immutable on the NFT, so the NFT gets the UUPS proxy rather than the implementation. + */ function deployNFTMetadataGenerator() public { - if (isTest || isTestnet) { - nftMetadataGenerator = new MockNFTMetadataGenerator(); - } else { - revert("NFTMetadataGenerator is not implemented yet for production"); + nftMetadataGeneratorImplementation = new NFTMetadataGenerator(); + bytes memory initializerData = abi.encodeCall(NFTMetadataGenerator.initialize, (deployerAddress)); + ERC1967Proxy proxy = new ERC1967Proxy(address(nftMetadataGeneratorImplementation), initializerData); + nftMetadataGenerator = INFTMetadataGenerator(address(proxy)); + + // Grant the admin role to the admins + for (uint256 i = 0; i < admins.length; i++) { + NFTMetadataGenerator(address(nftMetadataGenerator)).grantRole(Roles.DEFAULT_ADMIN_ROLE, admins[i]); } + + // Renounce the deployer's admin role (skipped when the deployer must keep it; see BaseScript.renounceUnlessAdmin) + renounceUnlessAdmin(address(nftMetadataGenerator), Roles.DEFAULT_ADMIN_ROLE); } function logNFTMetadataGenerator(string memory objectKey) public returns (string memory json) { diff --git a/src/NFTMetadataGenerator.sol b/src/NFTMetadataGenerator.sol new file mode 100644 index 0000000..15f22fe --- /dev/null +++ b/src/NFTMetadataGenerator.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {INFTMetadataGenerator} from "./interfaces/INFTMetadataGenerator.sol"; +import {MortgagePosition, MortgageStatus} from "./types/MortgagePosition.sol"; +import {Roles} from "./libraries/Roles.sol"; + +/** + * @title NFTMetadataGenerator + * @author SocksNFlops + * @notice The NFTMetadataGenerator contract renders a mortgage position as an ERC-721 metadata data URI + */ +contract NFTMetadataGenerator is Initializable, AccessControlUpgradeable, UUPSUpgradeable, INFTMetadataGenerator { + using Strings for address; + using Strings for uint256; + + /** + * @notice Storage structure for the NFTMetadataGenerator contract + * @custom:storage-location erc7201:buttonwood.storage.NFTMetadataGenerator + * @dev Uses ERC-7201 namespaced storage pattern + * @param _reserved Unused by this version. Reserves the namespace so a later version can add state. + */ + struct NFTMetadataGeneratorStorage { + uint256 _reserved; + } + + /** + * @dev The storage location of the NFTMetadataGenerator contract + * @dev keccak256(abi.encode(uint256(keccak256("buttonwood.storage.NFTMetadataGenerator")) - 1)) & ~bytes32(uint256(0xff)) + */ + // solhint-disable-next-line const-name-snakecase + bytes32 private constant NFTMetadataGeneratorStorageLocation = + 0xf4bcf94fca67fc8b4686b21f2330cb0a7de98e8d9eaf5ccaad8b293b01c42600; + + /// @dev The data URI scheme prefix that every generated metadata string carries + string private constant METADATA_PREFIX = "data:application/json;base64,"; + + /// @dev The `name` prefix, completed with the position's tokenId + string private constant NAME_PREFIX = "Buttonwood Position #"; + + /// @dev The `description` rendered for every position + string private constant DESCRIPTION = + "A Buttonwood Cash mortgage position: fixed-term, non-liquidating credit secured by escrowed collateral."; + + /** + * @dev Gets the storage location of the NFTMetadataGenerator contract + * @return $ The storage location of the NFTMetadataGenerator contract + */ + function _getNFTMetadataGeneratorStorage() private pure returns (NFTMetadataGeneratorStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := NFTMetadataGeneratorStorageLocation + } + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /** + * @notice Initializes the NFTMetadataGenerator contract + * @param admin The address granted DEFAULT_ADMIN_ROLE, which gates upgrades + */ + function initialize(address admin) external initializer { + __AccessControl_init(); + __UUPSUpgradeable_init(); + _grantRole(Roles.DEFAULT_ADMIN_ROLE, admin); + } + + /** + * @dev Authorizes the upgrade of the contract. Only the admin can authorize the upgrade + * @param newImplementation The address of the new implementation + */ + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address newImplementation) internal virtual override onlyRole(Roles.DEFAULT_ADMIN_ROLE) {} + + /** + * @inheritdoc INFTMetadataGenerator + */ + function generateMetadata(MortgagePosition memory mortgagePosition) + external + pure + override + returns (string memory metadata) + { + string memory json = string.concat( + "{\"name\":\"", + NAME_PREFIX, + mortgagePosition.tokenId.toString(), + "\",\"description\":\"", + DESCRIPTION, + "\",\"attributes\":[", + _attributes(mortgagePosition), + "]}" + ); + metadata = string.concat(METADATA_PREFIX, Base64.encode(bytes(json))); + } + + /** + * @dev Renders every meaningful field of the position as a comma-separated list of attribute objects, + * without the enclosing brackets. Built in groups because one concat over every attribute runs out of stack. + * @param position The position of the mortgage + * @return attributes The comma-separated attribute objects + */ + function _attributes(MortgagePosition memory position) internal pure returns (string memory attributes) { + attributes = string.concat( + _collateralAttributes(position), + ",", + _termAttributes(position), + ",", + _balanceAttributes(position), + ",", + _statusAttributes(position) + ); + } + + /** + * @dev Renders the collateral-side attributes of the position + * @param position The position of the mortgage + * @return attributes The comma-separated attribute objects + */ + function _collateralAttributes(MortgagePosition memory position) internal pure returns (string memory attributes) { + attributes = string.concat( + _stringAttribute("collateral", position.collateral.toHexString()), + ",", + _numberAttribute("collateralDecimals", position.collateralDecimals), + ",", + _amountAttribute("collateralAmount", position.collateralAmount), + ",", + _amountAttribute("collateralConverted", position.collateralConverted), + ",", + _stringAttribute("subConsol", position.subConsol.toHexString()) + ); + } + + /** + * @dev Renders the rate and term attributes of the position. Rates are in basis points and dates are + * unix timestamps, both as the mortgage records them. + * @param position The position of the mortgage + * @return attributes The comma-separated attribute objects + */ + function _termAttributes(MortgagePosition memory position) internal pure returns (string memory attributes) { + attributes = string.concat( + _numberAttribute("interestRate", position.interestRate), + ",", + _numberAttribute("conversionPremiumRate", position.conversionPremiumRate), + ",", + _numberAttribute("dateOriginated", position.dateOriginated), + ",", + _numberAttribute("termOriginated", position.termOriginated), + ",", + _numberAttribute("totalPeriods", position.totalPeriods) + ); + } + + /** + * @dev Renders the principal and payment balances of the position + * @param position The position of the mortgage + * @return attributes The comma-separated attribute objects + */ + function _balanceAttributes(MortgagePosition memory position) internal pure returns (string memory attributes) { + attributes = string.concat( + _amountAttribute("termBalance", position.termBalance), + ",", + _amountAttribute("amountBorrowed", position.amountBorrowed), + ",", + _amountAttribute("amountPrior", position.amountPrior), + ",", + _amountAttribute("termPaid", position.termPaid), + ",", + _amountAttribute("termConverted", position.termConverted), + ",", + _amountAttribute("amountConverted", position.amountConverted) + ); + } + + /** + * @dev Renders the penalty, payment-plan and lifecycle attributes of the position + * @param position The position of the mortgage + * @return attributes The comma-separated attribute objects + */ + function _statusAttributes(MortgagePosition memory position) internal pure returns (string memory attributes) { + attributes = string.concat( + _amountAttribute("penaltyAccrued", position.penaltyAccrued), + ",", + _amountAttribute("penaltyPaid", position.penaltyPaid), + ",", + _numberAttribute("paymentsMissed", position.paymentsMissed), + ",", + _boolAttribute("hasPaymentPlan", position.hasPaymentPlan), + ",", + _stringAttribute("status", _statusLabel(position.status)) + ); + } + + /** + * @dev Renders one attribute whose value is a JSON string. Every value passed here is an address or a + * fixed label, so none of them can contain a character that JSON would need escaped. + * @param traitType The name of the trait + * @param value The value of the trait + * @return attribute The attribute object + */ + function _stringAttribute(string memory traitType, string memory value) + internal + pure + returns (string memory attribute) + { + attribute = string.concat("{\"trait_type\":\"", traitType, "\",\"value\":\"", value, "\"}"); + } + + /** + * @dev Renders one attribute whose value is a JSON number. Only used for values small enough to survive + * a consumer parsing them as a double. + * @param traitType The name of the trait + * @param value The value of the trait + * @return attribute The attribute object + */ + function _numberAttribute(string memory traitType, uint256 value) internal pure returns (string memory attribute) { + attribute = string.concat("{\"trait_type\":\"", traitType, "\",\"value\":", value.toString(), "}"); + } + + /** + * @dev Renders one token amount as an attribute. Amounts are quoted rather than emitted as JSON numbers + * because they routinely exceed what a consumer parsing JSON into doubles can hold exactly. + * @param traitType The name of the trait + * @param value The amount of the trait + * @return attribute The attribute object + */ + function _amountAttribute(string memory traitType, uint256 value) internal pure returns (string memory attribute) { + attribute = _stringAttribute(traitType, value.toString()); + } + + /** + * @dev Renders one attribute whose value is a JSON boolean + * @param traitType The name of the trait + * @param value The value of the trait + * @return attribute The attribute object + */ + function _boolAttribute(string memory traitType, bool value) internal pure returns (string memory attribute) { + attribute = string.concat("{\"trait_type\":\"", traitType, "\",\"value\":", value ? "true" : "false", "}"); + } + + /** + * @dev Maps a mortgage status onto its label + * @param status The status of the mortgage + * @return label The label for the status + */ + function _statusLabel(MortgageStatus status) internal pure returns (string memory label) { + if (status == MortgageStatus.ACTIVE) { + return "ACTIVE"; + } + if (status == MortgageStatus.FORECLOSED) { + return "FORECLOSED"; + } + return "REDEEMED"; + } +} diff --git a/test/NFTMetadataGenerator.t.sol b/test/NFTMetadataGenerator.t.sol new file mode 100644 index 0000000..b434a25 --- /dev/null +++ b/test/NFTMetadataGenerator.t.sol @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {BaseTest} from "./BaseTest.t.sol"; +import {NFTMetadataGenerator} from "../src/NFTMetadataGenerator.sol"; +import {MockNFTMetadataGeneratorUpgraded} from "./mocks/MockNFTMetadataGeneratorUpgraded.sol"; +import {MortgageNFT} from "../src/MortgageNFT.sol"; +import {MortgagePosition, MortgageStatus} from "../src/types/MortgagePosition.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {Roles} from "../src/libraries/Roles.sol"; + +contract NFTMetadataGeneratorTest is BaseTest { + string public constant METADATA_PREFIX = "data:application/json;base64,"; + string public constant DESCRIPTION = + "A Buttonwood Cash mortgage position: fixed-term, non-liquidating credit secured by escrowed collateral."; + + NFTMetadataGenerator public generatorImplementation; + NFTMetadataGenerator public generator; + + function setUp() public virtual override { + super.setUp(); + generatorImplementation = new NFTMetadataGenerator(); + bytes memory initializerData = abi.encodeCall(NFTMetadataGenerator.initialize, (admin)); + generator = NFTMetadataGenerator(address(new ERC1967Proxy(address(generatorImplementation), initializerData))); + } + + /*////////////////////////////////////////////////////////////// + INITIALIZATION + //////////////////////////////////////////////////////////////*/ + + function test_initialize_grantsAdminRole() public view { + assertTrue(generator.hasRole(Roles.DEFAULT_ADMIN_ROLE, admin), "Admin should hold DEFAULT_ADMIN_ROLE"); + } + + function test_initialize_grantsNoOtherAdmin(address other) public view { + vm.assume(other != admin); + assertFalse(generator.hasRole(Roles.DEFAULT_ADMIN_ROLE, other), "Only the initializer's admin should hold the role"); + } + + function test_initialize_revertWhenAlreadyInitialized() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + generator.initialize(admin); + } + + function test_initialize_revertOnImplementation() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + generatorImplementation.initialize(admin); + } + + /*////////////////////////////////////////////////////////////// + UPGRADES + //////////////////////////////////////////////////////////////*/ + + function test_upgradeTo_revertWhenNotAdmin(address caller) public { + vm.assume(caller != admin); + + MockNFTMetadataGeneratorUpgraded newImplementation = new MockNFTMetadataGeneratorUpgraded(); + + vm.startPrank(caller); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, Roles.DEFAULT_ADMIN_ROLE) + ); + generator.upgradeToAndCall(address(newImplementation), ""); + vm.stopPrank(); + } + + function test_upgradeTo_isAdmin(bytes32 salt) public { + MockNFTMetadataGeneratorUpgraded newImplementation = new MockNFTMetadataGeneratorUpgraded{salt: salt}(); + + vm.startPrank(admin); + generator.upgradeToAndCall(address(newImplementation), ""); + vm.stopPrank(); + + assertTrue( + MockNFTMetadataGeneratorUpgraded(address(generator)).newFunction(), + "generator should have the new implementation functions" + ); + // The upgrade preserves the role that authorized it + assertTrue(generator.hasRole(Roles.DEFAULT_ADMIN_ROLE, admin), "Admin should still hold DEFAULT_ADMIN_ROLE"); + } + + /*////////////////////////////////////////////////////////////// + GENERATE METADATA + //////////////////////////////////////////////////////////////*/ + + function test_generateMetadata_matchesExpectedJson() public view { + string memory metadata = generator.generateMetadata(_samplePosition(MortgageStatus.ACTIVE)); + string memory expectedJson = _expectedSampleJson("ACTIVE"); + + // The payload is the exact base64 of the expected JSON, cross-checked against forge's own encoder + assertEq(metadata, string.concat(METADATA_PREFIX, vm.toBase64(bytes(expectedJson))), "Metadata data URI mismatch"); + // ...and decoding it back independently yields that same JSON + assertEq(_decodeMetadata(metadata), expectedJson, "Decoded metadata mismatch"); + } + + function test_generateMetadata_decodesToParseableJson() public view { + string memory decoded = _decodeMetadata(generator.generateMetadata(_samplePosition(MortgageStatus.ACTIVE))); + + assertEq(vm.parseJsonString(decoded, ".name"), "Buttonwood Position #7", "Name mismatch"); + assertEq(vm.parseJsonString(decoded, ".description"), DESCRIPTION, "Description mismatch"); + assertFalse(vm.keyExistsJson(decoded, ".image"), "v1 must not emit an image key"); + + string[21] memory traitTypes = _expectedTraitTypes(); + for (uint256 i = 0; i < traitTypes.length; i++) { + assertEq( + vm.parseJsonString(decoded, string.concat(".attributes[", vm.toString(i), "].trait_type")), + traitTypes[i], + string.concat("Attribute #", vm.toString(i), " trait_type mismatch") + ); + } + assertFalse(vm.keyExistsJson(decoded, ".attributes[21]"), "Attribute count mismatch"); + + assertEq(vm.parseJsonString(decoded, ".attributes[0].value"), _sampleCollateral(), "Collateral value mismatch"); + assertEq(vm.parseJsonUint(decoded, ".attributes[1].value"), 8, "Collateral decimals mismatch"); + assertEq(vm.parseJsonString(decoded, ".attributes[11].value"), "100000000000000000000000", "Borrowed mismatch"); + assertTrue(vm.parseJsonBool(decoded, ".attributes[19].value"), "Payment plan flag mismatch"); + assertEq(vm.parseJsonString(decoded, ".attributes[20].value"), "ACTIVE", "Status mismatch"); + } + + function test_generateMetadata_statusLabels() public view { + string[3] memory labels = ["ACTIVE", "FORECLOSED", "REDEEMED"]; + for (uint256 i = 0; i < labels.length; i++) { + string memory decoded = _decodeMetadata(generator.generateMetadata(_samplePosition(MortgageStatus(i)))); + assertEq(decoded, _expectedSampleJson(labels[i]), string.concat("Status JSON mismatch: ", labels[i])); + } + } + + function test_generateMetadata_tokenIdInName(uint256 tokenId) public view { + MortgagePosition memory position = _samplePosition(MortgageStatus.ACTIVE); + position.tokenId = tokenId; + + string memory decoded = _decodeMetadata(generator.generateMetadata(position)); + assertEq( + vm.parseJsonString(decoded, ".name"), + string.concat("Buttonwood Position #", vm.toString(tokenId)), + "Name should carry the tokenId" + ); + } + + /*////////////////////////////////////////////////////////////// + TOKEN URI END TO END + //////////////////////////////////////////////////////////////*/ + + function test_tokenURI_rendersRealPosition() public { + // Open a real mortgage so the LoanManager holds a position for tokenId 1 + _mintUsdx(lender, 606_000e18); + vm.startPrank(lender); + usdx.approve(address(originationPool), 606_000e18); + originationPool.deposit(606_000e18); + vm.stopPrank(); + vm.warp(originationPool.deployPhaseTimestamp()); + _requestNoncompoundingPaymentPlanMortgage(borrower, "mortgage1", 100_000e18, 2e8, address(0)); + + // The generator pointer is immutable on MortgageNFT, so point a fresh NFT at the real generator. + // It still resolves positions through the GeneralManager's LoanManager, which now holds tokenId 1. + MortgageNFT nft = + new MortgageNFT(MORTGAGE_NFT_NAME, MORTGAGE_NFT_SYMBOL, address(generalManager), address(generator)); + vm.prank(address(generalManager)); + uint256 tokenId = nft.mint(borrower, "mortgage1"); + + MortgagePosition memory position = loanManager.getMortgagePosition(tokenId); + string memory decoded = _decodeMetadata(nft.tokenURI(tokenId)); + + assertEq( + vm.parseJsonString(decoded, ".name"), + string.concat("Buttonwood Position #", vm.toString(tokenId)), + "Name mismatch" + ); + assertEq( + vm.parseJsonString(decoded, ".attributes[0].value"), + Strings.toHexString(address(wbtc)), + "Collateral should be the mortgage's collateral" + ); + assertEq( + vm.parseJsonString(decoded, ".attributes[11].value"), + vm.toString(position.amountBorrowed), + "Borrowed amount should match the position" + ); + assertEq(vm.parseJsonString(decoded, ".attributes[20].value"), "ACTIVE", "A fresh mortgage is ACTIVE"); + } + + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + /// @dev The attribute order the generator emits, which follows the MortgagePosition struct + function _expectedTraitTypes() internal pure returns (string[21] memory traitTypes) { + traitTypes = [ + "collateral", + "collateralDecimals", + "collateralAmount", + "collateralConverted", + "subConsol", + "interestRate", + "conversionPremiumRate", + "dateOriginated", + "termOriginated", + "totalPeriods", + "termBalance", + "amountBorrowed", + "amountPrior", + "termPaid", + "termConverted", + "amountConverted", + "penaltyAccrued", + "penaltyPaid", + "paymentsMissed", + "hasPaymentPlan", + "status" + ]; + } + + function _sampleCollateral() internal pure returns (string memory collateral) { + collateral = Strings.toHexString(address(0xBEEF)); + } + + function _samplePosition(MortgageStatus status) internal pure returns (MortgagePosition memory position) { + position = MortgagePosition({ + tokenId: 7, + collateral: address(0xBEEF), + collateralDecimals: 8, + collateralAmount: 2e8, + collateralConverted: 1e7, + subConsol: address(0xCAFE), + interestRate: 425, + conversionPremiumRate: 5000, + dateOriginated: 1_700_000_000, + termOriginated: 1_700_086_400, + termBalance: 120_000e18, + amountBorrowed: 100_000e18, + amountPrior: 5_000e18, + termPaid: 2_500e18, + termConverted: 1_000e18, + amountConverted: 3_000e18, + penaltyAccrued: 42e18, + penaltyPaid: 12e18, + paymentsMissed: 3, + totalPeriods: 36, + hasPaymentPlan: true, + status: status + }); + } + + /// @dev The JSON literal the sample position must render to, written out rather than rebuilt from the contract's pieces + function _expectedSampleJson(string memory statusLabel) internal pure returns (string memory json) { + json = string.concat( + '{"name":"Buttonwood Position #7","description":"', + DESCRIPTION, + '","attributes":[', + '{"trait_type":"collateral","value":"0x000000000000000000000000000000000000beef"},', + '{"trait_type":"collateralDecimals","value":8},', + '{"trait_type":"collateralAmount","value":"200000000"},', + '{"trait_type":"collateralConverted","value":"10000000"},', + '{"trait_type":"subConsol","value":"0x000000000000000000000000000000000000cafe"},', + '{"trait_type":"interestRate","value":425},', + '{"trait_type":"conversionPremiumRate","value":5000},', + '{"trait_type":"dateOriginated","value":1700000000},', + '{"trait_type":"termOriginated","value":1700086400},', + '{"trait_type":"totalPeriods","value":36},', + _expectedSampleBalances(), + '{"trait_type":"penaltyAccrued","value":"42000000000000000000"},', + '{"trait_type":"penaltyPaid","value":"12000000000000000000"},', + '{"trait_type":"paymentsMissed","value":3},', + '{"trait_type":"hasPaymentPlan","value":true},', + '{"trait_type":"status","value":"', + statusLabel, + '"}]}' + ); + } + + function _expectedSampleBalances() internal pure returns (string memory json) { + json = string.concat( + '{"trait_type":"termBalance","value":"120000000000000000000000"},', + '{"trait_type":"amountBorrowed","value":"100000000000000000000000"},', + '{"trait_type":"amountPrior","value":"5000000000000000000000"},', + '{"trait_type":"termPaid","value":"2500000000000000000000"},', + '{"trait_type":"termConverted","value":"1000000000000000000000"},', + '{"trait_type":"amountConverted","value":"3000000000000000000000"},' + ); + } + + /// @dev Asserts the data URI prefix and returns the decoded JSON behind it + function _decodeMetadata(string memory metadata) internal pure returns (string memory json) { + bytes memory raw = bytes(metadata); + bytes memory prefix = bytes(METADATA_PREFIX); + require(raw.length > prefix.length, "metadata: shorter than its prefix"); + for (uint256 i = 0; i < prefix.length; i++) { + require(raw[i] == prefix[i], "metadata: not a json base64 data uri"); + } + + bytes memory payload = new bytes(raw.length - prefix.length); + for (uint256 i = 0; i < payload.length; i++) { + payload[i] = raw[prefix.length + i]; + } + json = string(_base64Decode(payload)); + } + + /// @dev Standard-alphabet base64 decoder, independent of the encoder the contract uses + function _base64Decode(bytes memory data) internal pure returns (bytes memory decoded) { + require(data.length % 4 == 0, "base64: bad length"); + if (data.length == 0) { + return decoded; + } + + uint256 padding = 0; + if (data[data.length - 1] == "=") { + padding++; + } + if (data[data.length - 2] == "=") { + padding++; + } + + decoded = new bytes((data.length / 4) * 3 - padding); + uint256 written = 0; + for (uint256 i = 0; i < data.length; i += 4) { + uint256 chunk = (_base64Value(data[i]) << 18) | (_base64Value(data[i + 1]) << 12) + | (_base64Value(data[i + 2]) << 6) | _base64Value(data[i + 3]); + if (written < decoded.length) { + decoded[written++] = bytes1(uint8(chunk >> 16)); + } + if (written < decoded.length) { + decoded[written++] = bytes1(uint8(chunk >> 8)); + } + if (written < decoded.length) { + decoded[written++] = bytes1(uint8(chunk)); + } + } + } + + function _base64Value(bytes1 character) internal pure returns (uint256 value) { + uint8 code = uint8(character); + if (code >= 65 && code <= 90) { + return code - 65; // A-Z + } + if (code >= 97 && code <= 122) { + return code - 97 + 26; // a-z + } + if (code >= 48 && code <= 57) { + return code - 48 + 52; // 0-9 + } + if (code == 43) { + return 62; // + + } + if (code == 47) { + return 63; // / + } + require(code == 61, "base64: bad character"); + return 0; // = padding + } +} diff --git a/test/mocks/MockNFTMetadataGeneratorUpgraded.sol b/test/mocks/MockNFTMetadataGeneratorUpgraded.sol new file mode 100644 index 0000000..6c8de8c --- /dev/null +++ b/test/mocks/MockNFTMetadataGeneratorUpgraded.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {NFTMetadataGenerator} from "../../src/NFTMetadataGenerator.sol"; + +/** + * @title MockNFTMetadataGeneratorUpgraded + * @author SocksNFlops + * @notice Just a mock to test the upgrade of the NFTMetadataGenerator + */ +contract MockNFTMetadataGeneratorUpgraded is NFTMetadataGenerator { + function newFunction() public pure returns (bool) { + return true; + } +} diff --git a/test/script/DeployAllProduction.t.sol b/test/script/DeployAllProduction.t.sol new file mode 100644 index 0000000..cdea9b3 --- /dev/null +++ b/test/script/DeployAllProduction.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {DeployAllTest} from "./DeployAll.t.sol"; +import {MockERC20} from "../mocks/MockERC20.sol"; +import {MockPyth} from "@pythnetwork/MockPyth.sol"; +import {MortgageNFT} from "../../src/MortgageNFT.sol"; +import {NFTMetadataGenerator} from "../../src/NFTMetadataGenerator.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Roles} from "../../src/libraries/Roles.sol"; + +/** + * @notice Runs DeployAll in production mode (neither IS_TEST nor IS_TESTNET) and verifies the pieces that + * only exist on that path: the mock-only deploys are skipped and the NFT metadata generator is the real + * UUPS proxy the MortgageNFT points at. + * @dev The mode is flipped through the script's setter rather than IS_TEST / IS_TESTNET, because vm.setEnv + * writes process-global state and every other DeployAll suite depends on those two variables reading as + * test mode (see DeployAllRoleInvariants.t.sol). The production-only variables written here are additive: + * no test-mode suite ever reads them, so a parallel suite cannot observe a divergent value. + */ +contract DeployAllProductionTest is DeployAllTest { + string public constant METADATA_PREFIX = "data:application/json;base64,"; + + function testId() public pure virtual override returns (string memory) { + return type(DeployAllProductionTest).name; + } + + function setUp() public virtual override { + super.setUp(); + + // Production takes its collateral, USD tokens and Pyth contract as given rather than deploying them + vm.setEnv("COLLATERAL_ADDRESS_1", vm.toString(address(new MockERC20("Wrapped Bitcoin", "WBTC", 8)))); + vm.setEnv("USD_ADDRESS_0", vm.toString(address(new MockERC20("Tether USD", "USDT0", 6)))); + vm.setEnv("USD_ADDRESS_1", vm.toString(address(new MockERC20("USD Coin", "USDC", 6)))); + vm.setEnv("PYTH_ADDRESS", vm.toString(address(new MockPyth(120, 0)))); + + deployAll.setDeployMode(false, false); + } + + function run() public virtual override { + super.run(); + + assertFalse(deployAll.isTest(), "Script should have run in production mode"); + assertFalse(deployAll.isTestnet(), "Script should have run in production mode"); + + address generator = address(deployAll.nftMetadataGenerator()); + address implementation = address(deployAll.nftMetadataGeneratorImplementation()); + + assertTrue(generator != address(0), "nftMetadataGeneratorAddress should be non-zero in production"); + assertTrue(implementation != address(0), "The generator implementation should have been deployed"); + assertTrue(generator != implementation, "The generator should be the proxy, not the implementation"); + + // The logged address book carries the proxy + assertEq( + vm.parseJsonAddress(vm.readFile(deployAll.getPath()), ".nftMetadataGeneratorAddress"), + generator, + "Logged nftMetadataGeneratorAddress mismatch" + ); + + // The MortgageNFT's immutable pointer is the proxy + MortgageNFT mortgageNFT = MortgageNFT(address(deployAll.mortgageNFT())); + assertEq(mortgageNFT.nftMetadataGenerator(), generator, "MortgageNFT should point at the deployed generator"); + + // The proxy's admin role went to the configured admins and the deployer walked away without it + assertTrue( + IAccessControl(generator).hasRole(Roles.DEFAULT_ADMIN_ROLE, admin1), "First admin missing DEFAULT_ADMIN_ROLE" + ); + assertTrue( + IAccessControl(generator).hasRole(Roles.DEFAULT_ADMIN_ROLE, admin2), "Second admin missing DEFAULT_ADMIN_ROLE" + ); + assertFalse( + IAccessControl(generator).hasRole(Roles.DEFAULT_ADMIN_ROLE, deployerAddress), + "Deployer should have renounced DEFAULT_ADMIN_ROLE" + ); + + // A minted position renders through the real generator + vm.prank(address(deployAll.generalManager())); + uint256 tokenId = mortgageNFT.mint(address(this), "mortgage1"); + assertTrue(_hasMetadataPrefix(mortgageNFT.tokenURI(tokenId)), "tokenURI should be a json base64 data uri"); + } + + function _hasMetadataPrefix(string memory uri) internal pure returns (bool hasPrefix) { + bytes memory raw = bytes(uri); + bytes memory prefix = bytes(METADATA_PREFIX); + if (raw.length <= prefix.length) { + return false; + } + for (uint256 i = 0; i < prefix.length; i++) { + if (raw[i] != prefix[i]) { + return false; + } + } + hasPrefix = true; + } +}