From 8e98755f420b0dd3789a5643b7bc41d6c8803f8d Mon Sep 17 00:00:00 2001 From: SocksNFlops Date: Tue, 22 Sep 2026 08:13:38 +0900 Subject: [PATCH 1/5] BW-185: skip mock-only deploys on a production DeployAll run deployYieldStrategies() and deployNFTMetadataGenerator() deploy test mocks and revert when IS_TEST and IS_TESTNET are both false, so DeployAll could not complete a mainnet run. Gate both calls on the mock modes instead: production SubConsols launch without a yield strategy and the LoanManager with a zero metadata generator, matching the live 999 stack. --- script/DeployAll.s.sol | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/script/DeployAll.s.sol b/script/DeployAll.s.sol index 577a841..ccfccc8 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,10 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan deployInterestOracle(); // Deploy PriceOracles that read from the PythOracle deployPriceOracles(); - // Deploy NFTMetadataGenerator - deployNFTMetadataGenerator(); // Disabled for production + // Deploy NFTMetadataGenerator (mock; production LoanManager launches with none) + if (isTest || isTestnet) { + deployNFTMetadataGenerator(); + } // Deploy GeneralManager deployGeneralManager(); // Configure the origination fee From 4e706f2729f338b6b22e5636527c437d3fade6ca Mon Sep 17 00:00:00 2001 From: SocksNFlops Date: Tue, 22 Sep 2026 09:09:41 +0900 Subject: [PATCH 2/5] BW-197: add the upgradeable NFTMetadataGenerator Renders a MortgagePosition as a data:application/json;base64 URI with the position's fields as ERC-721 attributes. UUPS + AccessControl behind ERC-7201 namespaced storage, with upgrades gated by DEFAULT_ADMIN_ROLE. --- src/NFTMetadataGenerator.sol | 263 +++++++++++++ test/NFTMetadataGenerator.t.sol | 353 ++++++++++++++++++ .../MockNFTMetadataGeneratorUpgraded.sol | 15 + 3 files changed, 631 insertions(+) create mode 100644 src/NFTMetadataGenerator.sol create mode 100644 test/NFTMetadataGenerator.t.sol create mode 100644 test/mocks/MockNFTMetadataGeneratorUpgraded.sol diff --git a/src/NFTMetadataGenerator.sol b/src/NFTMetadataGenerator.sol new file mode 100644 index 0000000..d91af8b --- /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; + } +} From 861c525f90480b4d1bd769283654f8e08bbf337f Mon Sep 17 00:00:00 2001 From: SocksNFlops Date: Tue, 22 Sep 2026 09:09:51 +0900 Subject: [PATCH 3/5] BW-197: deploy the NFTMetadataGenerator proxy in production DeployAll calls deployNFTMetadataGenerator() unconditionally again: production deploys the implementation, an ERC1967Proxy and the initializer, then hands the admin role to the configured admins. Test and testnet keep the mock. The deploy mode is settable so the production script test can run without writing IS_TEST / IS_TESTNET, which forge shares across parallel suites, and the addresses file is chosen by the test-only suffix so that suite writes its own. --- script/BaseScript.s.sol | 12 ++++ script/DeployAll.s.sol | 16 +++-- script/DeployNFTMetadataGenerator.s.sol | 25 ++++++- test/script/DeployAllProduction.t.sol | 95 +++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 test/script/DeployAllProduction.t.sol 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 ccfccc8..b174540 100644 --- a/script/DeployAll.s.sol +++ b/script/DeployAll.s.sol @@ -47,10 +47,8 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan deployInterestOracle(); // Deploy PriceOracles that read from the PythOracle deployPriceOracles(); - // Deploy NFTMetadataGenerator (mock; production LoanManager launches with none) - if (isTest || isTestnet) { - deployNFTMetadataGenerator(); - } + // Deploy NFTMetadataGenerator (the UUPS proxy in production, the settable mock in test/testnet) + deployNFTMetadataGenerator(); // Deploy GeneralManager deployGeneralManager(); // Configure the origination fee @@ -121,6 +119,10 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan assertContractRoleInvariants(address(orderPool), "OrderPool"); assertContractRoleInvariants(address(generalManager), "GeneralManager"); assertContractRoleInvariants(address(originationPoolScheduler), "OriginationPoolScheduler"); + // The test/testnet metadata generator is the mock, which carries no roles + if (!isTest && !isTestnet) { + assertContractRoleInvariants(address(nftMetadataGenerator), "NFTMetadataGenerator"); + } } /** @@ -145,8 +147,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..4cd9473 100644 --- a/script/DeployNFTMetadataGenerator.s.sol +++ b/script/DeployNFTMetadataGenerator.s.sol @@ -3,9 +3,13 @@ pragma solidity ^0.8.20; import {BaseScript} from "./BaseScript.s.sol"; import {INFTMetadataGenerator} from "../src/interfaces/INFTMetadataGenerator.sol"; +import {NFTMetadataGenerator} from "../src/NFTMetadataGenerator.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {MockNFTMetadataGenerator} from "../test/mocks/MockNFTMetadataGenerator.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 +23,29 @@ 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 production gets the UUPS proxy rather than the + * implementation. Test and testnet keep the mock, whose metadata string is settable. + */ function deployNFTMetadataGenerator() public { if (isTest || isTestnet) { nftMetadataGenerator = new MockNFTMetadataGenerator(); - } else { - revert("NFTMetadataGenerator is not implemented yet for production"); + return; } + + 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/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; + } +} From ae8ede4b2562fe81a309abecf10a6b2d6fb3d991 Mon Sep 17 00:00:00 2001 From: SocksNFlops Date: Tue, 22 Sep 2026 09:23:25 +0900 Subject: [PATCH 4/5] BW-197: double-quote the generator's JSON literals for solhint --- src/NFTMetadataGenerator.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/NFTMetadataGenerator.sol b/src/NFTMetadataGenerator.sol index d91af8b..15f22fe 100644 --- a/src/NFTMetadataGenerator.sol +++ b/src/NFTMetadataGenerator.sol @@ -90,12 +90,12 @@ contract NFTMetadataGenerator is Initializable, AccessControlUpgradeable, UUPSUp returns (string memory metadata) { string memory json = string.concat( - '{"name":"', + "{\"name\":\"", NAME_PREFIX, mortgagePosition.tokenId.toString(), - '","description":"', + "\",\"description\":\"", DESCRIPTION, - '","attributes":[', + "\",\"attributes\":[", _attributes(mortgagePosition), "]}" ); @@ -211,7 +211,7 @@ contract NFTMetadataGenerator is Initializable, AccessControlUpgradeable, UUPSUp pure returns (string memory attribute) { - attribute = string.concat('{"trait_type":"', traitType, '","value":"', value, '"}'); + attribute = string.concat("{\"trait_type\":\"", traitType, "\",\"value\":\"", value, "\"}"); } /** @@ -222,7 +222,7 @@ contract NFTMetadataGenerator is Initializable, AccessControlUpgradeable, UUPSUp * @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(), "}"); + attribute = string.concat("{\"trait_type\":\"", traitType, "\",\"value\":", value.toString(), "}"); } /** @@ -243,7 +243,7 @@ contract NFTMetadataGenerator is Initializable, AccessControlUpgradeable, UUPSUp * @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", "}"); + attribute = string.concat("{\"trait_type\":\"", traitType, "\",\"value\":", value ? "true" : "false", "}"); } /** From a9f863ac3db25196dedfc998c5d9aba1b89b2995 Mon Sep 17 00:00:00 2001 From: SocksNFlops Date: Tue, 22 Sep 2026 11:04:26 +0900 Subject: [PATCH 5/5] BW-197: deploy the NFTMetadataGenerator proxy in every mode --- script/DeployAll.s.sol | 7 ++----- script/DeployNFTMetadataGenerator.s.sol | 9 +-------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/script/DeployAll.s.sol b/script/DeployAll.s.sol index b174540..2390f5d 100644 --- a/script/DeployAll.s.sol +++ b/script/DeployAll.s.sol @@ -47,7 +47,7 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan deployInterestOracle(); // Deploy PriceOracles that read from the PythOracle deployPriceOracles(); - // Deploy NFTMetadataGenerator (the UUPS proxy in production, the settable mock in test/testnet) + // Deploy NFTMetadataGenerator (UUPS proxy) deployNFTMetadataGenerator(); // Deploy GeneralManager deployGeneralManager(); @@ -119,10 +119,7 @@ contract DeployAll is DeployOriginationScheduler, DeployOrderPool, DeployLoanMan assertContractRoleInvariants(address(orderPool), "OrderPool"); assertContractRoleInvariants(address(generalManager), "GeneralManager"); assertContractRoleInvariants(address(originationPoolScheduler), "OriginationPoolScheduler"); - // The test/testnet metadata generator is the mock, which carries no roles - if (!isTest && !isTestnet) { - assertContractRoleInvariants(address(nftMetadataGenerator), "NFTMetadataGenerator"); - } + assertContractRoleInvariants(address(nftMetadataGenerator), "NFTMetadataGenerator"); } /** diff --git a/script/DeployNFTMetadataGenerator.s.sol b/script/DeployNFTMetadataGenerator.s.sol index 4cd9473..0395bf3 100644 --- a/script/DeployNFTMetadataGenerator.s.sol +++ b/script/DeployNFTMetadataGenerator.s.sol @@ -5,7 +5,6 @@ import {BaseScript} from "./BaseScript.s.sol"; import {INFTMetadataGenerator} from "../src/interfaces/INFTMetadataGenerator.sol"; import {NFTMetadataGenerator} from "../src/NFTMetadataGenerator.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {MockNFTMetadataGenerator} from "../test/mocks/MockNFTMetadataGenerator.sol"; import {Roles} from "../src/libraries/Roles.sol"; contract DeployNFTMetadataGenerator is BaseScript { @@ -25,15 +24,9 @@ contract DeployNFTMetadataGenerator is BaseScript { /** * @notice Deploys the NFT metadata generator the LoanManager's MortgageNFT will point at. - * @dev The pointer is immutable on the NFT, so production gets the UUPS proxy rather than the - * implementation. Test and testnet keep the mock, whose metadata string is settable. + * @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(); - return; - } - nftMetadataGeneratorImplementation = new NFTMetadataGenerator(); bytes memory initializerData = abi.encodeCall(NFTMetadataGenerator.initialize, (deployerAddress)); ERC1967Proxy proxy = new ERC1967Proxy(address(nftMetadataGeneratorImplementation), initializerData);