├── .github └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── README.md ├── foundry.toml ├── script └── DeployStackScore.sol ├── slither.config.json ├── src ├── AbstractNFT.sol ├── StackScore.sol ├── StackScoreRenderer.sol ├── dynamic-traits │ ├── DynamicTraits.sol │ ├── OnchainTraits.sol │ ├── interfaces │ │ └── IERC7496.sol │ └── lib │ │ └── TraitLabelLib.sol ├── interfaces │ ├── IERC5192.sol │ └── IPreapprovalForAll.sol ├── lib │ └── Constants.sol ├── onchain │ ├── Metadata.sol │ └── json.sol └── tokens │ └── erc721 │ ├── ERC721ConduitPreapproved_Solady.sol │ └── ERC721Preapproved_Solady.sol └── test ├── StackScore.t.sol └── StackScoreRenderer.t.sol /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: workflow_dispatch 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | check: 10 | strategy: 11 | fail-fast: true 12 | 13 | name: Foundry project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | submodules: recursive 19 | 20 | - name: Install Foundry 21 | uses: foundry-rs/foundry-toolchain@v1 22 | with: 23 | version: nightly 24 | 25 | - name: Run Forge build 26 | run: | 27 | forge --version 28 | forge build --sizes 29 | id: build 30 | 31 | - name: Run Forge tests 32 | run: | 33 | forge test -vvv 34 | id: test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # local files 6 | local/ 7 | 8 | # Ignores development broadcast logs 9 | /broadcast 10 | /broadcast/*/31337/ 11 | /broadcast/**/dry-run/ 12 | 13 | # Docs 14 | docs/ 15 | 16 | # Dotenv file 17 | .env 18 | 19 | .DS_Store 20 | .vscode 21 | .idea 22 | 23 | # coverage 24 | html 25 | lcov.info 26 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/solady"] 5 | path = lib/solady 6 | url = https://github.com/vectorized/solady 7 | [submodule "lib/solarray"] 8 | path = lib/solarray 9 | url = https://github.com/evmcheb/solarray 10 | [submodule "lib/openzeppelin-contracts"] 11 | path = lib/openzeppelin-contracts 12 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stack Score 2 | 3 | ## Description 4 | 5 | Stack Score is an onchain reputation score calculated democratically and progressively, using a bottom-up and co-determined approach. It reflects a multifaceted 6 | social reputation score for the onchain economy that’s credible, trustworthy, and entirely onchain. 7 | 8 | ## This repo includes 9 | 10 | - StackScore.sol: The main contract that holds the reputation score. 11 | - StackScoreRenderer.sol: The contract that renders the reputation score and an onchain SVG. 12 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | optimizer_runs = 200 6 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 7 | -------------------------------------------------------------------------------- /script/DeployStackScore.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.24; 3 | 4 | import "forge-std/Script.sol"; 5 | import {StackScore} from "../src/StackScore.sol"; 6 | import {StackScoreRenderer} from "../src/StackScoreRenderer.sol"; 7 | import {TraitLabel, Editors, FullTraitValue, AllowedEditor, EditorsLib} from "src/dynamic-traits/lib/TraitLabelLib.sol"; 8 | import {DisplayType} from "src/onchain/Metadata.sol"; 9 | 10 | contract DeployStackScore is Script { 11 | // Constants 12 | address public constant expectedOwnerAddress = 0x42c22eBD6f07FC052040137eEb3B8a1b7A38b275; 13 | address public constant CONTRACT_ADDRESS = 0x555555555C68dfEE1288c4372E8BbAF272062F4e; 14 | bytes32 private constant SALT = 0x15c58a5b1e6d53b84801c46ff3f819ea0a1f902bd8246ab7fd4d68db52441994; 15 | bytes32 private constant EXPECTED_CODE_HASH = 0xbd0b8beb3197c867250274b48ed6f2438be5cda669b1e508b8e5b0b994db5645; 16 | 17 | // Variables 18 | StackScore public token; 19 | StackScoreRenderer public renderer; 20 | address public deployer; 21 | address public fundsReceiver; 22 | 23 | // Functions 24 | function assertCodeHash(address initialOwner) internal pure { 25 | bytes memory constructorArgs = abi.encode(initialOwner); 26 | // Log the constructorArgs 27 | console.logBytes(constructorArgs); 28 | 29 | bytes32 initCodeHash = keccak256( 30 | abi.encodePacked( 31 | type(StackScore).creationCode, constructorArgs 32 | ) 33 | ); 34 | console.logBytes32(initCodeHash); 35 | require(initCodeHash == EXPECTED_CODE_HASH, "Unexpected init code hash"); 36 | } 37 | 38 | function assertAddress() public view { 39 | require( 40 | address(token) == CONTRACT_ADDRESS, 41 | "Deployed address does not match expected address" 42 | ); 43 | } 44 | 45 | function assertExpectedOwner() public view { 46 | require( 47 | token.owner() == expectedOwnerAddress, 48 | "Owner address does not match expected address" 49 | ); 50 | } 51 | 52 | function assertInitialOwner(address initialOwner) public pure { 53 | require( 54 | initialOwner == expectedOwnerAddress, 55 | "Initial owner address does not match expected address" 56 | ); 57 | } 58 | 59 | function run() external { 60 | address signer = 0xAf052e84C39A5F8DA2027acF83A0fcd6fCF1D8B8; 61 | uint256 key = vm.envUint("PRIVATE_KEY"); // Get the private key from the environment. 62 | deployer = vm.addr(key); // Get the address of the private key. 63 | assertInitialOwner(deployer); // Assert the initial owner. 64 | assertCodeHash(deployer); // Assert the code hash. 65 | console.log(deployer); 66 | fundsReceiver = vm.envAddress("FUNDS_RECEIVER"); // Get the funds receiver address from the environment. 67 | vm.startBroadcast(key); // Start the broadcast with the private key. 68 | renderer = new StackScoreRenderer(); // Deploy the renderer contract. 69 | token = new StackScore{salt: SALT}(deployer); // Deploy the token contract. 70 | assertExpectedOwner(); // Assert the expected owner. 71 | token.setMintFeeRecipient(payable(fundsReceiver)); // Set the funds receiver. 72 | token.setRenderer(address(renderer)); // Set the renderer address. 73 | TraitLabel memory scoreLabel = TraitLabel({ 74 | fullTraitKey: "score", 75 | traitLabel: "Score", 76 | acceptableValues: new string[](0), 77 | fullTraitValues: new FullTraitValue[](0), 78 | displayType: DisplayType.Number, 79 | editors: Editors.wrap(EditorsLib.toBitMap(AllowedEditor.Self)), 80 | required: true 81 | }); 82 | token.setTraitLabel(bytes32("score"), scoreLabel); 83 | TraitLabel memory paletteLabel = TraitLabel({ 84 | fullTraitKey: "paletteIndex", 85 | traitLabel: "PaletteIndex", 86 | acceptableValues: new string[](0), 87 | fullTraitValues: new FullTraitValue[](0), 88 | displayType: DisplayType.Number, 89 | editors: Editors.wrap(EditorsLib.toBitMap(AllowedEditor.Self)), 90 | required: true 91 | }); 92 | token.setTraitLabel(bytes32("paletteIndex"), paletteLabel); 93 | TraitLabel memory updatedLabel = TraitLabel({ 94 | fullTraitKey: "updatedAt", 95 | traitLabel: "UpdatedAt", 96 | acceptableValues: new string[](0), 97 | fullTraitValues: new FullTraitValue[](0), 98 | displayType: DisplayType.Number, 99 | editors: Editors.wrap(EditorsLib.toBitMap(AllowedEditor.Self)), 100 | required: true 101 | }); 102 | token.setTraitLabel(bytes32("updatedAt"), updatedLabel); 103 | token.setSigner(signer); // Set the signer address. 104 | assertAddress(); // Assert the address of the token contract. 105 | vm.stopBroadcast(); // Stop the broadcast. 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /slither.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "filter_paths": "lib" 3 | } 4 | -------------------------------------------------------------------------------- /src/AbstractNFT.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {ERC721ConduitPreapproved_Solady, ERC721} from "./tokens/erc721/ERC721ConduitPreapproved_Solady.sol"; 5 | import {json} from "./onchain/json.sol"; 6 | import {Metadata} from "./onchain/Metadata.sol"; 7 | import {LibString} from "solady/utils/LibString.sol"; 8 | import {Solarray} from "solarray/Solarray.sol"; 9 | import {OnchainTraits, DynamicTraits} from "./dynamic-traits/OnchainTraits.sol"; 10 | 11 | // @authors Modified from: https://github.com/ProjectOpenSea/shipyard-core/blob/main/src/reference/AbstractNFT.sol 12 | abstract contract AbstractNFT is OnchainTraits, ERC721ConduitPreapproved_Solady { 13 | string _name; 14 | string _symbol; 15 | 16 | constructor(string memory name_, string memory symbol_) { 17 | _name = name_; 18 | _symbol = symbol_; 19 | } 20 | 21 | function name() public view virtual override returns (string memory) { 22 | return _name; 23 | } 24 | 25 | function symbol() public view virtual override returns (string memory) { 26 | return _symbol; 27 | } 28 | 29 | /** 30 | * @notice Get the metdata URI for a given token ID 31 | * @param tokenId The token ID to get the tokenURI for 32 | */ 33 | function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { 34 | return _stringURI(tokenId); 35 | } 36 | 37 | /** 38 | * @notice Helper function to get the raw JSON metadata representing a given token ID 39 | * @param tokenId The token ID to get URI for 40 | */ 41 | function _stringURI(uint256 tokenId) internal view virtual returns (string memory) { 42 | return ""; 43 | } 44 | 45 | /** 46 | * @notice Helper function to get both the static and dynamic attributes for a given token ID 47 | * @param tokenId The token ID to get the static and dynamic attributes for 48 | */ 49 | function _attributes(uint256 tokenId) internal view virtual returns (string memory) { 50 | // get the static attributes 51 | string[] memory staticTraits = _staticAttributes(tokenId); 52 | // get the dynamic attributes 53 | string[] memory dynamicTraits = _dynamicAttributes(tokenId); 54 | 55 | // return the combined attributes as a property containing an array 56 | return json.rawProperty("attributes", json.arrayOf(staticTraits, dynamicTraits)); 57 | } 58 | 59 | /** 60 | * @notice Helper function to get the static attributes for a given token ID 61 | * @param tokenId The token ID to get the static attributes for 62 | */ 63 | function _staticAttributes(uint256 tokenId) internal view virtual returns (string[] memory); 64 | 65 | /** 66 | * @notice Helper function to get the raw SVG image for a given token ID 67 | * @param tokenId The token ID to get the dynamic attributes for 68 | */ 69 | function _image(uint256 tokenId) internal view virtual returns (string memory); 70 | 71 | function supportsInterface(bytes4 interfaceId) public view virtual override(DynamicTraits, ERC721) returns (bool) { 72 | return DynamicTraits.supportsInterface(interfaceId) || ERC721.supportsInterface(interfaceId); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/StackScore.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | // External libraries 5 | import {LibString} from "solady/utils/LibString.sol"; 6 | import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol"; 7 | import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; 8 | import {ECDSA} from "solady/utils/ECDSA.sol"; 9 | import {Solarray} from "solarray/Solarray.sol"; 10 | // Internal imports 11 | import {json} from "./onchain/json.sol"; 12 | import {Metadata, DisplayType} from "./onchain/Metadata.sol"; 13 | import {TraitLib} from "./dynamic-traits/lib/TraitLabelLib.sol"; 14 | import {AbstractNFT} from "./AbstractNFT.sol"; 15 | import {StackScoreRenderer} from "./StackScoreRenderer.sol"; 16 | import {IERC5192} from "./interfaces/IERC5192.sol"; 17 | 18 | /// @title StackScore 19 | /// @notice A fully onchain, dynamic, soulbound token for reputation scores 20 | /// @notice Each score is rendered as an onchain SVG. 21 | /// @author strangechances (g@stack.so) 22 | contract StackScore is AbstractNFT, IERC5192, ReentrancyGuard { 23 | /// @notice The current version of the contract. 24 | string public constant version = "1"; 25 | /// @notice The name of the token. 26 | string internal constant _tokenName = "Stack Score"; 27 | /// @notice The description of the token. 28 | string internal constant _tokenDescription = "A dynamic, onchain, soulbound reputation score"; 29 | /// @notice The signer address. 30 | /// @dev The signer address is used to verify the score signature. 31 | address public signer; 32 | /// @notice The mint fee. 33 | uint256 public mintFee = 0.001 ether; 34 | /// @notice The referral fee percentage, in basis points. 35 | /// @dev This is a percentage of the mint fee, in basis points (100 basis points is 1%). 36 | uint256 public referralBps = 5000; 37 | /// @notice Address to token ID mapping. 38 | /// @dev Prevents multiple tokens from being minted for the same address. 39 | mapping(address => uint256) public addressToTokenId; 40 | /// @notice Signature mapping, to prevent replay attacks. 41 | /// @dev This is used to prevent replay attacks. 42 | mapping(bytes32 => bool) internal signatures; 43 | /// @notice The current token ID. 44 | uint256 internal currentId; 45 | /// @notice The renderer contract. 46 | /// @dev This contract is used to render the SVG image. 47 | StackScoreRenderer internal renderer; 48 | /// @notice The mint fee recipient. 49 | /// @dev This address receives the mint fee, minus any referral fee. 50 | address public mintFeeRecipient; 51 | 52 | /// @notice Emitted when the score is updated. 53 | event ScoreUpdated(address account, uint256 tokenId, uint256 oldScore, uint256 newScore); 54 | /// @notice Emitted when a token is minted. 55 | event Minted(address to, uint256 tokenId); 56 | /// @notice Emitted when a referral is paid. 57 | event ReferralPaid(address referrer, address referred, uint256 amount); 58 | /// @notice Emitted when the mint fee is updated. 59 | event MintFeeUpdated(uint256 oldFee, uint256 newFee); 60 | /// @notice Emitted when the mint fee recipient is updated. 61 | event MintFeeRecipientUpdated(address oldRecipient, address newRecipient); 62 | /// @notice Emitted when the referral fee is updated. 63 | /// @dev This is a percentage of the mint fee, in basis points (100 basis points is 1%). 64 | event ReferralBpsUpdated(uint256 oldBps, uint256 newBps); 65 | /// @notice Emitted when the renderer contract is updated. 66 | event RendererUpdated(address oldRenderer, address newRenderer); 67 | /// @notice Emitted when the signer address is updated. 68 | event SignerUpdated(address oldSigner, address newSigner); 69 | 70 | /// @notice Error thrown when the token is locked upon transfer. 71 | /// @dev The token is Soulbound according to the ERC-5192 standard. 72 | error TokenLocked(uint256 tokenId); 73 | /// @notice Error thrown when the signature is invalid. 74 | error InvalidSignature(); 75 | /// @notice Error thrown when the signature is already used. 76 | error SignatureAlreadyUsed(); 77 | /// @notice Error thrown when the mint fee is insufficient. 78 | error InsufficientFee(); 79 | /// @notice Error thrown when the sender is not the token owner. 80 | /// @dev For example, when a non-owner tries to update a score's color palette. 81 | error OnlyTokenOwner(); 82 | /// @notice Error thrown when a given timestamp is older than the last update for a score. 83 | error TimestampTooOld(); 84 | /// @notice Error thrown if mint called for the second time for the same address. 85 | error OneTokenPerAddress(); 86 | 87 | /// @notice Constructor 88 | /// @dev Set the name and symbol of the token. 89 | constructor(address initialOwner) AbstractNFT(_tokenName, "STACK_SCORE") { 90 | _initializeOwner(initialOwner); 91 | } 92 | 93 | /// @notice Mint a new soulbound token. 94 | /// @dev Mint a new token and lock it. 95 | /// @dev The mint fee is sent to the mint fee recipient. 96 | /// @dev Does not require a signature, since there is no score. 97 | /// @param to The address to mint the token to. 98 | /// @return The token ID. 99 | function mint(address to) payable public nonReentrant returns (uint256) { 100 | _assertSufficientFee(); 101 | 102 | SafeTransferLib.safeTransferETH(mintFeeRecipient, msg.value); 103 | _mintTo(to); 104 | 105 | return currentId; 106 | } 107 | 108 | /// @notice Mint a new soulbound token with a referral. 109 | /// @dev Mint a new token, lock it, and send a referral fee. 110 | /// @dev Does not need to check a signature, since there is no score. 111 | /// @param to The address to mint the token to. 112 | /// @param referrer The address to send the referral fee to. 113 | /// @return The token ID. 114 | function mintWithReferral( 115 | address to, 116 | address referrer 117 | ) payable public nonReentrant returns (uint256) { 118 | _assertSufficientFee(); 119 | 120 | uint256 referralAmount = _getReferralAmount(msg.value); 121 | SafeTransferLib.safeTransferETH(mintFeeRecipient, msg.value - referralAmount); 122 | emit ReferralPaid(referrer, to, referralAmount); 123 | SafeTransferLib.safeTransferETH(referrer, referralAmount); 124 | _mintTo(to); 125 | 126 | return currentId; 127 | } 128 | 129 | /// @notice Mint a new soulbound token with a score. 130 | /// @dev Mint a new token, lock it, and update the score. 131 | /// @param to The address to mint the token to. 132 | /// @param score The score to set. 133 | /// @param signature The signature to verify. 134 | /// @return The token ID. 135 | function mintWithScore( 136 | address to, 137 | uint256 score, 138 | uint256 timestamp, 139 | bytes memory signature 140 | ) payable public returns (uint256) { 141 | mint(to); 142 | updateScore(currentId, score, timestamp, signature); 143 | return currentId; 144 | } 145 | 146 | /// @notice Mint a new soulbound token with a score and palette. 147 | /// @dev Mint a new token, lock it, update the score, and update the palette. 148 | /// @param to The address to mint the token to. 149 | /// @param score The score to set. 150 | /// @param palette The palette index to set. 151 | /// @param signature The signature to verify. 152 | /// @return The token ID. 153 | function mintWithScoreAndPalette( 154 | address to, 155 | uint256 score, 156 | uint256 timestamp, 157 | uint256 palette, 158 | bytes memory signature 159 | ) payable public returns (uint256) { 160 | mint(to); 161 | updateScore(currentId, score, timestamp, signature); 162 | updatePalette(currentId, palette); 163 | return currentId; 164 | } 165 | 166 | /// @notice Mint a new soulbound token with a score and referral. 167 | /// @dev Mint a new token, lock it, update the score, and send a referral fee. 168 | /// @param to The address to mint the token to. 169 | /// @param score The score to set. 170 | /// @param referrer The address to send the referral fee to. 171 | /// @param signature The signature to verify. 172 | /// @return The token ID. 173 | function mintWithScoreAndReferral( 174 | address to, 175 | uint256 score, 176 | uint256 timestamp, 177 | address referrer, 178 | bytes memory signature 179 | ) payable public returns (uint256) { 180 | mintWithReferral(to, referrer); 181 | updateScore(currentId, score, timestamp, signature); 182 | return currentId; 183 | } 184 | 185 | /// @notice Mint a new soulbound token with a score, referral, and palette. 186 | /// @dev Mint a new token, lock it, update the score, send a referral fee, and update the palette. 187 | /// @param to The address to mint the token to. 188 | /// @param score The score to set. 189 | /// @param referrer The address to send the referral fee to. 190 | /// @param palette The palette index to set. 191 | /// @param signature The signature to verify. 192 | /// @return The token ID. 193 | function mintWithScoreAndReferralAndPalette( 194 | address to, 195 | uint256 score, 196 | uint256 timestamp, 197 | address referrer, 198 | uint256 palette, 199 | bytes memory signature 200 | ) payable public returns (uint256) { 201 | mintWithReferral(to, referrer); 202 | updateScore(currentId, score, timestamp, signature); 203 | updatePalette(currentId, palette); 204 | return currentId; 205 | } 206 | 207 | /// @notice Mint a new soulbound token with a referral and palette. 208 | /// @dev Mint a new token, lock it, send a referral fee, and update the palette. 209 | /// @dev Does not require a signature, since there is no score. 210 | /// @param to The address to mint the token to. 211 | /// @param referrer The address to send the referral fee to. 212 | /// @param palette The palette index to set. 213 | /// @return The token ID. 214 | function mintWithReferralAndPalette( 215 | address to, 216 | address referrer, 217 | uint256 palette 218 | ) payable public nonReentrant returns (uint256) { 219 | mintWithReferral(to, referrer); 220 | updatePalette(currentId, palette); 221 | return currentId; 222 | } 223 | 224 | /// @notice Mint a new soulbound token with a palette. 225 | /// @dev Mint a new token, lock it, and update the palette. 226 | /// @dev Does not require a signature, since there is no score. 227 | /// @param to The address to mint the token to. 228 | /// @param palette The palette index to set. 229 | function mintWithPalette( 230 | address to, 231 | uint256 palette 232 | ) payable public nonReentrant returns (uint256) { 233 | mint(to); 234 | updatePalette(currentId, palette); 235 | return currentId; 236 | } 237 | 238 | /// @notice Update the score for a given token ID. 239 | /// @dev The score is signed by the signer for the account. 240 | /// @param tokenId The token ID to update. 241 | /// @param newScore The new score. 242 | /// @param signature The signature to verify. 243 | function updateScore(uint256 tokenId, uint256 newScore, uint256 timestamp, bytes memory signature) public { 244 | uint256 oldScore = uint256(getTraitValue(tokenId, "score")); 245 | if (newScore == 0 && oldScore == 0) { 246 | // No need to update the score if it's already 0. 247 | return; 248 | } 249 | address account = ownerOf(tokenId); 250 | _assertValidTimestamp(tokenId, timestamp); 251 | _assertValidScoreSignature(account, newScore, timestamp, signature); 252 | this.setTrait(tokenId, "updatedAt", bytes32(timestamp)); 253 | this.setTrait(tokenId, "score", bytes32(newScore)); 254 | emit ScoreUpdated(account, tokenId, oldScore, newScore); 255 | } 256 | 257 | /// @notice Update the palette index for a given token ID. 258 | /// @dev The palette index is the index of the palette to use for rendering. 259 | /// @dev Only the owner can update the palette index. 260 | /// @param tokenId The token ID to update. 261 | function updatePalette(uint256 tokenId, uint256 paletteIndex) public { 262 | _assertTokenOwner(tokenId); 263 | this.setTrait(tokenId, "paletteIndex", bytes32(paletteIndex)); 264 | } 265 | 266 | /// @notice Check if a token is locked. 267 | /// @dev The token is Soulbound according to the ERC-5192 standard. 268 | /// @param tokenId The token ID to check. 269 | function locked(uint256 tokenId) public pure override returns (bool) { 270 | return true; 271 | } 272 | 273 | /// @notice Get the score for a given account. 274 | /// @param account The account to get the score for. 275 | /// @return The score. 276 | function getScore(address account) public view returns (uint256) { 277 | return uint256(getTraitValue(addressToTokenId[account], "score")); 278 | } 279 | 280 | /// @notice Get the updated at timestamp for a given token ID. 281 | /// @dev The updated at timestamp is the timestamp of the last score update. 282 | /// @param tokenId The token ID to get the updated at timestamp for. 283 | /// @return The updated at timestamp. 284 | function getUpdatedAt(uint256 tokenId) public view returns (uint256) { 285 | return uint256(getTraitValue(tokenId, "updatedAt")); 286 | } 287 | 288 | /// @notice Get the score and last updated timestamp for a given account. 289 | /// @dev The score is the reputation score aggregated from Stack leaderboards, and the last updated timestamp. 290 | /// @param account The account to get the score and last updated timestamp for. 291 | /// @return The score and last updated timestamp. 292 | function getScoreAndLastUpdated(address account) public view returns (uint256, uint256) { 293 | uint256 tokenId = addressToTokenId[account]; 294 | return ( 295 | uint256(getTraitValue(tokenId, "score")), 296 | uint256(getTraitValue(tokenId, "updatedAt")) 297 | ); 298 | } 299 | 300 | /// @notice Get the palette index for a given token ID. 301 | /// @param tokenId The token ID to get the palette index for. 302 | /// @return The palette index. 303 | function getPaletteIndex(uint256 tokenId) public view returns (uint256) { 304 | return uint256(getTraitValue(tokenId, "paletteIndex")); 305 | } 306 | 307 | /// @notice Get the current token ID. 308 | /// @return The current token ID. 309 | function getCurrentId() public view returns (uint256) { 310 | return currentId; 311 | } 312 | 313 | /// @notice Get the renderer contract address. 314 | /// @return The renderer contract address. 315 | function getRenderer() public view returns (address) { 316 | return address(renderer); 317 | } 318 | 319 | /// @notice Set the renderer contract address. 320 | /// @dev Only the owner can set the renderer contract address. 321 | /// @param _renderer The renderer contract address. 322 | function setRenderer(address _renderer) public onlyOwner { 323 | address oldRenderer = address(renderer); 324 | renderer = StackScoreRenderer(_renderer); 325 | emit RendererUpdated(oldRenderer, _renderer); 326 | } 327 | 328 | /// @notice Set the signer address. 329 | /// @dev Only the owner can set the signer address. 330 | /// @param _signer The signer address. 331 | function setSigner(address _signer) public onlyOwner { 332 | address oldSigner = signer; 333 | signer = _signer; 334 | emit SignerUpdated(oldSigner, _signer); 335 | } 336 | 337 | /// @notice Set the mint fee. 338 | /// @dev Only the owner can set the mint fee. 339 | function setMintFee(uint256 fee) public onlyOwner { 340 | uint256 oldFee = mintFee; 341 | mintFee = fee; 342 | emit MintFeeUpdated(oldFee, mintFee); 343 | } 344 | 345 | /// @notice Set the referral fee percentage. 346 | /// @dev Only the owner can set the referral fee percentage. 347 | /// @param bps The referral fee percentage, in basis points. 348 | function setReferralBps(uint256 bps) public onlyOwner { 349 | referralBps = bps; 350 | emit ReferralBpsUpdated(referralBps, bps); 351 | } 352 | 353 | /// @notice Set the mint fee recipient. 354 | /// @dev Only the owner can set the mint fee recipient. 355 | /// @param _mintFeeRecipient The mint fee recipient address. 356 | function setMintFeeRecipient(address _mintFeeRecipient) public onlyOwner { 357 | address oldFeeRecipient = mintFeeRecipient; 358 | mintFeeRecipient = _mintFeeRecipient; 359 | emit MintFeeRecipientUpdated(oldFeeRecipient, mintFeeRecipient); 360 | } 361 | 362 | function _getReferralAmount(uint256 amount) internal view returns (uint256) { 363 | return amount * referralBps / 10000; 364 | } 365 | 366 | function _assertOneTokenPerAddress(address to) internal view { 367 | if (balanceOf(to) > 0) { 368 | revert OneTokenPerAddress(); 369 | } 370 | } 371 | 372 | /// @notice Mint a new soulbound token. 373 | /// @dev Mint a new token, lock it. 374 | /// @param to The address to mint the token to. 375 | function _mintTo(address to) internal { 376 | _assertOneTokenPerAddress(to); 377 | 378 | unchecked { 379 | _mint(to, ++currentId); 380 | } 381 | 382 | addressToTokenId[to] = currentId; 383 | 384 | emit Minted(to, currentId); 385 | emit Locked(currentId); 386 | } 387 | 388 | /// @notice Verify the signature for the score. 389 | /// @dev The function throws an error if the signature is invalid, or has been used before. 390 | /// @param account The account to verify the score for. 391 | /// @param score The score to verify. 392 | /// @param signature The signature to verify. 393 | function _assertValidScoreSignature(address account, uint256 score, uint256 timestamp, bytes memory signature) internal { 394 | if (signatures[keccak256(signature)]) { 395 | revert SignatureAlreadyUsed(); 396 | } 397 | signatures[keccak256(signature)] = true; 398 | bytes32 hash = ECDSA.toEthSignedMessageHash( 399 | keccak256(abi.encodePacked(account, score, timestamp)) 400 | ); 401 | if (ECDSA.recover(hash, signature) != signer) { 402 | revert InvalidSignature(); 403 | } 404 | } 405 | 406 | /// @notice Verify the sender is the owner of the token. 407 | /// @dev The function throws an error if the sender is not the owner of the token. 408 | /// @param tokenId The token ID to verify the owner for. 409 | function _assertTokenOwner(uint256 tokenId) internal view { 410 | if (msg.sender != ownerOf(tokenId)) { 411 | revert OnlyTokenOwner(); 412 | } 413 | } 414 | 415 | /// @notice Verify the timestamp is not too old. 416 | /// @dev The function throws an error if the timestamp is too old. 417 | /// @param tokenId The token ID to verify the timestamp for. 418 | /// @param timestamp The timestamp to verify. 419 | function _assertValidTimestamp(uint256 tokenId, uint256 timestamp) internal view { 420 | uint256 lastUpdatedAt = uint256(getTraitValue(tokenId, "updatedAt")); 421 | // Ensure the score is newer than the last update. 422 | if (lastUpdatedAt > timestamp) { 423 | revert TimestampTooOld(); 424 | } 425 | } 426 | 427 | function _assertSufficientFee() internal view { 428 | if (msg.value < mintFee) { 429 | revert InsufficientFee(); 430 | } 431 | } 432 | 433 | /// @notice Get the URI for the trait metadata 434 | /// @param tokenId The token ID to get URI for 435 | /// @return The trait metadata URI. 436 | function _stringURI(uint256 tokenId) internal view override returns (string memory) { 437 | return json.objectOf( 438 | Solarray.strings( 439 | json.property("name", _tokenName), 440 | json.property("description", _tokenDescription), 441 | json.property("image", Metadata.base64SvgDataURI(_image(tokenId))), 442 | _attributes(tokenId) 443 | ) 444 | ); 445 | } 446 | 447 | /// @notice Helper function to get the static attributes for a given token ID 448 | /// @dev The static attributes are the name and description. 449 | /// @param tokenId The token ID to get the static attributes for 450 | /// @return The static attributes. 451 | function _staticAttributes(uint256 tokenId) internal view virtual override returns (string[] memory) { 452 | return Solarray.strings( 453 | Metadata.attribute({traitType: "Score Version", value: version}) 454 | ); 455 | } 456 | 457 | /// @notice Run checks before token transfers 458 | /// @dev Only allow transfers from the zero address, since the token is soulbound. 459 | /// @param from The address the token is being transferred from 460 | /// @param tokenId The token ID 461 | function _beforeTokenTransfer(address from, address, uint256 tokenId) internal pure override { 462 | // if the token is being transferred from an address 463 | if (from != address(0)) { 464 | revert TokenLocked(tokenId); 465 | } 466 | } 467 | 468 | /// @notice Helper function to get the raw SVG image for a given token ID 469 | /// @dev The SVG image is rendered by the renderer contract. 470 | /// @param tokenId The token ID to get the dynamic attributes for 471 | /// @return The SVG image. 472 | function _image(uint256 tokenId) internal view virtual override returns (string memory) { 473 | address account = ownerOf(tokenId); 474 | uint256 paletteIndex = uint256(getTraitValue(tokenId, "paletteIndex")); 475 | uint256 score = uint256(getTraitValue(tokenId, "score")); 476 | uint256 updatedAt = uint256(getTraitValue(tokenId, "updatedAt")); 477 | return renderer.getSVG(score, account, paletteIndex, updatedAt); 478 | } 479 | 480 | /// @notice Check if the sender is the owner of the token or an approved operator. 481 | /// @param tokenId The token ID to check. 482 | /// @param addr The address to check. 483 | /// @return True if the address is the owner or an approved operator. 484 | function _isOwnerOrApproved(uint256 tokenId, address addr) internal view override returns (bool) { 485 | return addr == ownerOf(tokenId); 486 | } 487 | } 488 | -------------------------------------------------------------------------------- /src/StackScoreRenderer.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {LibString} from "solady/utils/LibString.sol"; 5 | import {DateTimeLib} from "solady/utils/DateTimeLib.sol"; 6 | 7 | /// @notice SVG renderer for Stack Scores 8 | /// @dev SVGs are rendered using a palette of colors, a score, address, and timestamp. 9 | /// @author strangechances 10 | contract StackScoreRenderer { 11 | /// @notice The color palettes to use for the SVGs. 12 | /// @dev A palette is 3 colors in the order: top, bottom, middle. 13 | /// @dev The colors are stored as bytes3 to save gas 14 | bytes3[3][11] private COLOR_PALETTES = [ 15 | [bytes3(0x4F4C42), bytes3(0xACA695), bytes3(0xDBD9D1)], 16 | [bytes3(0x1D3C86), bytes3(0x2960E7), bytes3(0x4DB0F3)], 17 | [bytes3(0x0331A6), bytes3(0x5E83AA), bytes3(0xD2B481)], 18 | [bytes3(0x412C3F), bytes3(0xDA6A87), bytes3(0xDF819A)], 19 | [bytes3(0x0B241A), bytes3(0x5BC793), bytes3(0x5E83AA)], 20 | [bytes3(0xEB90BE), bytes3(0xEC5E3B), bytes3(0xF9D85B)], 21 | [bytes3(0xA13120), bytes3(0xEB5640), bytes3(0xEBDE8F)], 22 | [bytes3(0x0331A6), bytes3(0xB6BEC6), bytes3(0xF6CB82)], 23 | [bytes3(0x3B7BF6), bytes3(0xB7CDCE), bytes3(0xF09ABD)], 24 | [bytes3(0x3579BD), bytes3(0xDEAC91), bytes3(0xEC6C3F)], 25 | [bytes3(0x301C28), bytes3(0xCFA37F), bytes3(0xD7482C)] 26 | ]; 27 | 28 | string[] private MONTHS = [ 29 | "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" 30 | ]; 31 | 32 | /// @notice Get the color at a given index in a given palette as a hex string 33 | /// @dev The color is returned as a hex string without a prefix 34 | /// @param paletteIndex The index of the palette to get the color from 35 | /// @param colorIndex The index of the color to get 36 | function getColorAsHexString(uint paletteIndex, uint colorIndex) public view returns (string memory) { 37 | return LibString.toHexStringNoPrefix( 38 | uint24(COLOR_PALETTES[paletteIndex][colorIndex]), 39 | 3 40 | ); 41 | } 42 | 43 | /// @notice Get the timestamp as a string in the format "MMM DD YYYY HH:MM UTC" 44 | /// @param timestamp The timestamp to convert to a string 45 | function getTimestampString(uint256 timestamp) public view returns (string memory) { 46 | ( 47 | uint year, 48 | uint month, 49 | uint day, 50 | uint hour, 51 | uint minute, 52 | ) = DateTimeLib.timestampToDateTime(timestamp); 53 | 54 | return string(abi.encodePacked( 55 | MONTHS[month - 1], " ", 56 | LibString.toString(day), " ", 57 | LibString.toString(year), " ", 58 | LibString.toString(hour), ":", 59 | LibString.toString(minute), " UTC" 60 | )); 61 | } 62 | 63 | /// @notice Generate the SVG for a given palette index 64 | /// @param paletteIndex The index of the palette to use 65 | /// @return The SVG as a string 66 | function generateColors(uint256 paletteIndex) private view returns (string memory) { 67 | return string( 68 | abi.encodePacked( 69 | '' 70 | '' 73 | '', 74 | '' 75 | '' 76 | '' 77 | '' 78 | '' 79 | '' 80 | '' 81 | '' 84 | '' 85 | '' 86 | '' 89 | '' 90 | ) 91 | ); 92 | } 93 | 94 | /// @notice Get the SVG for a given score, account, palette index, and last updated timestamp 95 | /// @dev The SVG is returned as a string 96 | /// @param score The score to render 97 | /// @param account The account to render 98 | /// @param paletteIndex The index of the palette to use 99 | /// @param lastUpdated The timestamp when the score was last updated 100 | /// @return The SVG as a string 101 | function getSVG( 102 | uint256 score, 103 | address account, 104 | uint256 paletteIndex, 105 | uint256 lastUpdated 106 | ) public view returns (string memory) { 107 | return string( 108 | abi.encodePacked( 109 | '' 110 | '' 111 | '' 112 | '' 113 | '', 114 | generateColors(paletteIndex), 115 | '' 116 | 'LAST UPDATED ', 117 | getTimestampString(lastUpdated), 118 | '' 119 | '', 120 | '' 121 | '', 122 | LibString.toString(score), 123 | '' 124 | '' 125 | '' 126 | '', 127 | LibString.toHexString(account), 128 | '' 129 | '' 130 | '' 131 | '' 132 | '' 133 | '' 134 | '' 135 | '' 136 | '' 137 | '' 138 | '' 139 | '' 140 | '' 141 | '' 142 | '' 143 | '' 144 | '' 145 | '' 146 | '' 147 | '' 148 | '' 149 | '' 150 | '' 151 | '' 152 | '' 153 | ) 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/dynamic-traits/DynamicTraits.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: CC0-1.0 2 | pragma solidity ^0.8.19; 3 | 4 | import {IERC7496} from "./interfaces/IERC7496.sol"; 5 | 6 | library DynamicTraitsStorage { 7 | struct Layout { 8 | /// @dev A mapping of token ID to a mapping of trait key to trait value. 9 | mapping(uint256 tokenId => mapping(bytes32 traitKey => bytes32 traitValue)) _traits; 10 | /// @dev An offchain string URI that points to a JSON file containing trait metadata. 11 | string _traitMetadataURI; 12 | } 13 | 14 | bytes32 internal constant STORAGE_SLOT = keccak256("contracts.storage.erc7496-dynamictraits"); 15 | 16 | function layout() internal pure returns (Layout storage l) { 17 | bytes32 slot = STORAGE_SLOT; 18 | assembly { 19 | l.slot := slot 20 | } 21 | } 22 | } 23 | 24 | /** 25 | * @title DynamicTraits 26 | * 27 | * @dev Implementation of [ERC-7496](https://eips.ethereum.org/EIPS/eip-7496) Dynamic Traits. 28 | * Uses a storage layout pattern for upgradeable contracts. 29 | * 30 | * Requirements: 31 | * - Overwrite `setTrait` with access role restriction. 32 | * - Expose a function for `setTraitMetadataURI` with access role restriction if desired. 33 | */ 34 | contract DynamicTraits is IERC7496 { 35 | using DynamicTraitsStorage for DynamicTraitsStorage.Layout; 36 | 37 | /** 38 | * @notice Get the value of a trait for a given token ID. 39 | * @param tokenId The token ID to get the trait value for 40 | * @param traitKey The trait key to get the value of 41 | */ 42 | function getTraitValue(uint256 tokenId, bytes32 traitKey) public view virtual returns (bytes32 traitValue) { 43 | // Return the trait value. 44 | return DynamicTraitsStorage.layout()._traits[tokenId][traitKey]; 45 | } 46 | 47 | /** 48 | * @notice Get the values of traits for a given token ID. 49 | * @param tokenId The token ID to get the trait values for 50 | * @param traitKeys The trait keys to get the values of 51 | */ 52 | function getTraitValues(uint256 tokenId, bytes32[] calldata traitKeys) 53 | public 54 | view 55 | virtual 56 | returns (bytes32[] memory traitValues) 57 | { 58 | // Set the length of the traitValues return array. 59 | uint256 length = traitKeys.length; 60 | traitValues = new bytes32[](length); 61 | 62 | // Assign each trait value to the corresopnding key. 63 | for (uint256 i = 0; i < length;) { 64 | bytes32 traitKey = traitKeys[i]; 65 | traitValues[i] = getTraitValue(tokenId, traitKey); 66 | unchecked { 67 | ++i; 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * @notice Get the URI for the trait metadata 74 | */ 75 | function getTraitMetadataURI() external view virtual returns (string memory labelsURI) { 76 | // Return the trait metadata URI. 77 | return DynamicTraitsStorage.layout()._traitMetadataURI; 78 | } 79 | 80 | /** 81 | * @notice Set the value of a trait for a given token ID. 82 | * Reverts if the trait value is unchanged. 83 | * @dev IMPORTANT: Override this method with access role restriction. 84 | * @param tokenId The token ID to set the trait value for 85 | * @param traitKey The trait key to set the value of 86 | * @param newValue The new trait value to set 87 | */ 88 | function setTrait(uint256 tokenId, bytes32 traitKey, bytes32 newValue) public virtual { 89 | // Revert if the new value is the same as the existing value. 90 | bytes32 existingValue = DynamicTraitsStorage.layout()._traits[tokenId][traitKey]; 91 | if (existingValue == newValue) { 92 | revert TraitValueUnchanged(); 93 | } 94 | 95 | // Set the new trait value. 96 | _setTrait(tokenId, traitKey, newValue); 97 | 98 | // Emit the event noting the update. 99 | emit TraitUpdated(traitKey, tokenId, newValue); 100 | } 101 | 102 | /** 103 | * @notice Set the trait value (without emitting an event). 104 | * @param tokenId The token ID to set the trait value for 105 | * @param traitKey The trait key to set the value of 106 | * @param newValue The new trait value to set 107 | */ 108 | function _setTrait(uint256 tokenId, bytes32 traitKey, bytes32 newValue) internal virtual { 109 | // Set the new trait value. 110 | DynamicTraitsStorage.layout()._traits[tokenId][traitKey] = newValue; 111 | } 112 | 113 | /** 114 | * @notice Set the URI for the trait metadata. 115 | * @param uri The new URI to set. 116 | */ 117 | function _setTraitMetadataURI(string memory uri) internal virtual { 118 | // Set the new trait metadata URI. 119 | DynamicTraitsStorage.layout()._traitMetadataURI = uri; 120 | 121 | // Emit the event noting the update. 122 | emit TraitMetadataURIUpdated(); 123 | } 124 | 125 | /** 126 | * @dev See {IERC165-supportsInterface}. 127 | */ 128 | function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { 129 | return interfaceId == type(IERC7496).interfaceId; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/dynamic-traits/OnchainTraits.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {DynamicTraits} from "./DynamicTraits.sol"; 5 | import {Metadata} from "../onchain/Metadata.sol"; 6 | import {Ownable} from "solady/auth/Ownable.sol"; 7 | import {SSTORE2} from "solady/utils/SSTORE2.sol"; 8 | import {EnumerableSet} from "openzeppelin-contracts/contracts/utils/structs/EnumerableSet.sol"; 9 | import { 10 | TraitLabelStorage, 11 | TraitLabelStorageLib, 12 | TraitLabel, 13 | TraitLabelLib, 14 | Editors, 15 | StoredTraitLabel, 16 | AllowedEditor, 17 | EditorsLib, 18 | StoredTraitLabelLib 19 | } from "./lib/TraitLabelLib.sol"; 20 | 21 | library OnchainTraitsStorage { 22 | struct Layout { 23 | /// @notice An enumerable set of all trait keys that have been set. 24 | EnumerableSet.Bytes32Set _traitKeys; 25 | /// @notice A mapping of traitKey to OnchainTraitsStorage.layout()._traitLabelStorage metadata. 26 | mapping(bytes32 traitKey => TraitLabelStorage traitLabelStorage) _traitLabelStorage; 27 | /// @notice An enumerable set of all accounts allowed to edit traits with a "Custom" editor privilege. 28 | EnumerableSet.AddressSet _customEditors; 29 | } 30 | 31 | bytes32 internal constant STORAGE_SLOT = keccak256("contracts.storage.erc7496-dynamictraits.onchaintraits"); 32 | 33 | function layout() internal pure returns (Layout storage l) { 34 | bytes32 slot = STORAGE_SLOT; 35 | assembly { 36 | l.slot := slot 37 | } 38 | } 39 | } 40 | 41 | abstract contract OnchainTraits is Ownable, DynamicTraits { 42 | using OnchainTraitsStorage for OnchainTraitsStorage.Layout; 43 | using EnumerableSet for EnumerableSet.Bytes32Set; 44 | using EnumerableSet for EnumerableSet.AddressSet; 45 | 46 | /// @notice Thrown when the caller does not have the privilege to set a trait 47 | error InsufficientPrivilege(); 48 | /// @notice Thrown when trying to set a trait that does not exist 49 | error TraitDoesNotExist(bytes32 traitKey); 50 | 51 | constructor() { 52 | _initializeOwner(msg.sender); 53 | } 54 | 55 | // ABSTRACT 56 | 57 | /// @notice Helper to determine if a given address has the AllowedEditor.TokenOwner privilege. 58 | function _isOwnerOrApproved(uint256 tokenId, address addr) internal view virtual returns (bool); 59 | 60 | // CUSTOM EDITORS 61 | 62 | /** 63 | * @notice Check if an address is a custom editor 64 | * @param editor The address to check 65 | */ 66 | function isCustomEditor(address editor) external view returns (bool) { 67 | return OnchainTraitsStorage.layout()._customEditors.contains(editor); 68 | } 69 | 70 | /** 71 | * @notice Add or remove an address as a custom editor 72 | * @param editor The address to add or remove 73 | * @param insert Whether to add or remove the address 74 | */ 75 | function updateCustomEditor(address editor, bool insert) external onlyOwner { 76 | if (insert) { 77 | OnchainTraitsStorage.layout()._customEditors.add(editor); 78 | } else { 79 | OnchainTraitsStorage.layout()._customEditors.remove(editor); 80 | } 81 | } 82 | 83 | /** 84 | * @notice Get the list of custom editors. This may revert if there are too many editors. 85 | */ 86 | function getCustomEditors() external view returns (address[] memory) { 87 | return OnchainTraitsStorage.layout()._customEditors.values(); 88 | } 89 | 90 | /** 91 | * @notice Get the number of custom editors 92 | */ 93 | function getCustomEditorsLength() external view returns (uint256) { 94 | return OnchainTraitsStorage.layout()._customEditors.length(); 95 | } 96 | 97 | /** 98 | * @notice Get the custom editor at a given index 99 | * @param index The index of the custom editor to get 100 | */ 101 | function getCustomEditorAt(uint256 index) external view returns (address) { 102 | return OnchainTraitsStorage.layout()._customEditors.at(index); 103 | } 104 | 105 | // LABELS URI 106 | 107 | /** 108 | * @notice Get the onchain URI for the trait metadata, encoded as a JSON data URI 109 | */ 110 | function getTraitMetadataURI() external view virtual override returns (string memory) { 111 | return Metadata.jsonDataURI(_getTraitMetadataJson()); 112 | } 113 | 114 | /** 115 | * @notice Get the raw JSON for the trait metadata 116 | */ 117 | function _getTraitMetadataJson() internal view returns (string memory) { 118 | bytes32[] memory keys = OnchainTraitsStorage.layout()._traitKeys.values(); 119 | return TraitLabelStorageLib.toLabelJson(OnchainTraitsStorage.layout()._traitLabelStorage, keys); 120 | } 121 | 122 | /** 123 | * @notice Return trait label storage information at a given key. 124 | */ 125 | function traitLabelStorage(bytes32 traitKey) external view returns (TraitLabelStorage memory) { 126 | return OnchainTraitsStorage.layout()._traitLabelStorage[traitKey]; 127 | } 128 | 129 | /** 130 | * @notice Set a trait for a given traitKey and tokenId. Checks that the caller has permission to set the trait, 131 | * and, if the TraitLabel specifies that the trait value must be validated, checks that the trait value 132 | * is valid. 133 | * @param tokenId The token ID to get the trait value for 134 | * @param traitKey The trait key to get the value of 135 | * @param newValue The new trait value 136 | */ 137 | function setTrait(uint256 tokenId, bytes32 traitKey, bytes32 newValue) public virtual override { 138 | TraitLabelStorage memory labelStorage = OnchainTraitsStorage.layout()._traitLabelStorage[traitKey]; 139 | StoredTraitLabel storedTraitLabel = labelStorage.storedLabel; 140 | if (!StoredTraitLabelLib.exists(storedTraitLabel)) { 141 | revert TraitDoesNotExist(traitKey); 142 | } 143 | _verifySetterPrivilege(labelStorage.allowedEditors, tokenId); 144 | 145 | if (labelStorage.valuesRequireValidation) { 146 | TraitLabelLib.validateAcceptableValue(StoredTraitLabelLib.load(storedTraitLabel), traitKey, newValue); 147 | } 148 | DynamicTraits.setTrait(tokenId, traitKey, newValue); 149 | } 150 | 151 | /** 152 | * @notice Set the TraitLabel for a given traitKey. This will overwrite any existing TraitLabel for the traitKey. 153 | * Traits may not be set without a corresponding TraitLabel. OnlyOwner. 154 | * @param traitKey The trait key to set the value of 155 | * @param _traitLabel The trait label to set 156 | */ 157 | function setTraitLabel(bytes32 traitKey, TraitLabel calldata _traitLabel) external virtual onlyOwner { 158 | _setTraitLabel(traitKey, _traitLabel); 159 | } 160 | 161 | /** 162 | * @notice Set the OnchainTraitsStorage.layout()._traitLabelStorage for a traitKey. Packs SSTORE2 value along with allowedEditors, required?, and 163 | * valuesRequireValidation? into a single storage slot for more efficient validation when setting trait values. 164 | */ 165 | function _setTraitLabel(bytes32 traitKey, TraitLabel memory _traitLabel) internal virtual { 166 | OnchainTraitsStorage.layout()._traitKeys.add(traitKey); 167 | OnchainTraitsStorage.layout()._traitLabelStorage[traitKey] = TraitLabelStorage({ 168 | allowedEditors: _traitLabel.editors, 169 | required: _traitLabel.required, 170 | valuesRequireValidation: _traitLabel.acceptableValues.length > 0, 171 | storedLabel: TraitLabelLib.store(_traitLabel) 172 | }); 173 | } 174 | 175 | /** 176 | * @notice Checks that the caller has permission to set a trait for a given allowed Editors set and token ID. 177 | * Reverts with InsufficientPrivilege if the caller does not have permission. 178 | * @param editors The allowed editors for this trait 179 | * @param tokenId The token ID the trait is being set for 180 | */ 181 | function _verifySetterPrivilege(Editors editors, uint256 tokenId) internal view { 182 | // anyone 183 | if (EditorsLib.contains(editors, AllowedEditor.Anyone)) { 184 | // short circuit 185 | return; 186 | } 187 | 188 | // tokenOwner 189 | if (EditorsLib.contains(editors, AllowedEditor.TokenOwner)) { 190 | if (_isOwnerOrApproved(tokenId, msg.sender)) { 191 | // short circuit 192 | return; 193 | } 194 | } 195 | // customEditor 196 | if (EditorsLib.contains(editors, AllowedEditor.Custom)) { 197 | if (OnchainTraitsStorage.layout()._customEditors.contains(msg.sender)) { 198 | // short circuit 199 | return; 200 | } 201 | } 202 | // contractOwner 203 | if (EditorsLib.contains(editors, AllowedEditor.ContractOwner)) { 204 | if (owner() == msg.sender) { 205 | // short circuit 206 | return; 207 | } 208 | } 209 | 210 | if (EditorsLib.contains(editors, AllowedEditor.Self)) { 211 | if (address(this) == msg.sender) { 212 | // short circuit 213 | return; 214 | } 215 | } 216 | 217 | revert InsufficientPrivilege(); 218 | } 219 | 220 | /** 221 | * @notice Gets the individual JSON objects for each dynamic trait set on this token by iterating over all 222 | * possible traitKeys and checking if the trait is set on the token. This is extremely inefficient 223 | * and should only be called offchain when rendering metadata. 224 | * @param tokenId The token ID to get the dynamic trait attributes for 225 | * @return An array of JSON objects, each representing a dynamic trait set on the token 226 | */ 227 | function _dynamicAttributes(uint256 tokenId) internal view virtual returns (string[] memory) { 228 | bytes32[] memory keys = OnchainTraitsStorage.layout()._traitKeys.values(); 229 | uint256 keysLength = keys.length; 230 | 231 | string[] memory attributes = new string[](keysLength); 232 | // keep track of how many traits are actually set 233 | uint256 num; 234 | for (uint256 i = 0; i < keysLength;) { 235 | bytes32 key = keys[i]; 236 | bytes32 trait = getTraitValue(tokenId, key); 237 | // check that the trait is set, otherwise, skip it 238 | if (trait != bytes32(0)) { 239 | attributes[num] = 240 | TraitLabelStorageLib.toAttributeJson(OnchainTraitsStorage.layout()._traitLabelStorage, key, trait); 241 | unchecked { 242 | ++num; 243 | } 244 | } 245 | unchecked { 246 | ++i; 247 | } 248 | } 249 | ///@solidity memory-safe-assembly 250 | assembly { 251 | // update attributes with actual length 252 | mstore(attributes, num) 253 | } 254 | 255 | return attributes; 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/dynamic-traits/interfaces/IERC7496.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: CC0-1.0 2 | pragma solidity ^0.8.19; 3 | 4 | interface IERC7496 { 5 | /* Events */ 6 | event TraitUpdated(bytes32 indexed traitKey, uint256 tokenId, bytes32 traitValue); 7 | event TraitUpdatedRange(bytes32 indexed traitKey, uint256 fromTokenId, uint256 toTokenId); 8 | event TraitUpdatedRangeUniformValue( 9 | bytes32 indexed traitKey, uint256 fromTokenId, uint256 toTokenId, bytes32 traitValue 10 | ); 11 | event TraitUpdatedList(bytes32 indexed traitKey, uint256[] tokenIds); 12 | event TraitUpdatedListUniformValue(bytes32 indexed traitKey, uint256[] tokenIds, bytes32 traitValue); 13 | event TraitMetadataURIUpdated(); 14 | 15 | /* Getters */ 16 | function getTraitValue(uint256 tokenId, bytes32 traitKey) external view returns (bytes32 traitValue); 17 | function getTraitValues(uint256 tokenId, bytes32[] calldata traitKeys) 18 | external 19 | view 20 | returns (bytes32[] memory traitValues); 21 | function getTraitMetadataURI() external view returns (string memory uri); 22 | 23 | /* Setters */ 24 | function setTrait(uint256 tokenId, bytes32 traitKey, bytes32 traitValue) external; 25 | 26 | /* Errors */ 27 | error TraitValueUnchanged(); 28 | } 29 | -------------------------------------------------------------------------------- /src/dynamic-traits/lib/TraitLabelLib.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {DisplayType, Metadata} from "../../onchain/Metadata.sol"; 5 | import {json} from "../../onchain/json.sol"; 6 | import {Solarray} from "solarray/Solarray.sol"; 7 | import {LibString} from "solady/utils/LibString.sol"; 8 | import {SSTORE2} from "solady/utils/SSTORE2.sol"; 9 | 10 | ///@notice Bitmap type for storing allowed editors 11 | type Editors is uint8; 12 | 13 | ///@notice alias type for storing and loading TraitLabels using SSTORE2 14 | type StoredTraitLabel is address; 15 | 16 | ///@notice Enumeration of allowed editor roles 17 | enum AllowedEditor { 18 | Anyone, 19 | Self, 20 | TokenOwner, 21 | Custom, 22 | ContractOwner 23 | } 24 | 25 | ///@notice Struct associating a bytes32 traitValue to its string representation 26 | struct FullTraitValue { 27 | bytes32 traitValue; 28 | string fullTraitValue; 29 | } 30 | 31 | ///@notice Struct representing a trait label 32 | struct TraitLabel { 33 | // The full trait key string if different from the bytes32 traitKey 34 | string fullTraitKey; 35 | // The string label for the trait 36 | string traitLabel; 37 | // The list of acceptable values for the trait, if it must be validated 38 | string[] acceptableValues; 39 | // The list of full trait values, if the trait value should be converted to a different string 40 | FullTraitValue[] fullTraitValues; 41 | // The display type for the trait 42 | DisplayType displayType; 43 | // The list of editors allowed to set the trait 44 | Editors editors; 45 | // Whether the trait is required to have a value 46 | bool required; 47 | } 48 | 49 | // Pack allowedEditors and valueRequiresValidation (for writes) plus storageAddress (for reads) into a single slot 50 | struct TraitLabelStorage { 51 | // The bitmap of editors allowed to set the trait 52 | Editors allowedEditors; 53 | // true if TraitLabel.required == true 54 | bool required; 55 | // true if TraitLabel.acceptableValues.length != 0 56 | bool valuesRequireValidation; 57 | // The address of the TraitLabel in contract storage, aliased as a StoredTraitLabel 58 | StoredTraitLabel storedLabel; 59 | } 60 | 61 | library TraitLabelStorageLib { 62 | /** 63 | * @notice Decode a TraitLabel from contract storage 64 | * @param labelStorage TraitLabelStorage 65 | */ 66 | function toTraitLabel(TraitLabelStorage memory labelStorage) internal view returns (TraitLabel memory) { 67 | return StoredTraitLabelLib.load(labelStorage.storedLabel); 68 | } 69 | 70 | /** 71 | * @notice Given a trait key and value, render it as a properly formatted JSON attribute 72 | * @param traitLabelStorage Storage mapping of trait keys to TraitLabelStorage 73 | * @param traitKey Trait key 74 | * @param traitValue Trait value 75 | */ 76 | function toAttributeJson( 77 | mapping(bytes32 traitKey => TraitLabelStorage traitLabelStorage) storage traitLabelStorage, 78 | bytes32 traitKey, 79 | bytes32 traitValue 80 | ) internal view returns (string memory) { 81 | // read and decode the trait label from contract storage 82 | TraitLabelStorage storage labelStorage = traitLabelStorage[traitKey]; 83 | TraitLabel memory traitLabel = toTraitLabel(labelStorage); 84 | 85 | string memory actualTraitValue; 86 | // convert traitValue if possible 87 | 88 | if (traitLabel.fullTraitValues.length != 0) { 89 | // try to find matching FullTraitValue 90 | uint256 length = traitLabel.fullTraitValues.length; 91 | for (uint256 i = 0; i < length;) { 92 | FullTraitValue memory fullTraitValue = traitLabel.fullTraitValues[i]; 93 | if (fullTraitValue.traitValue == traitValue) { 94 | actualTraitValue = fullTraitValue.fullTraitValue; 95 | break; 96 | } 97 | unchecked { 98 | ++i; 99 | } 100 | } 101 | } 102 | // if no match, use traitValue as-is 103 | if (bytes(actualTraitValue).length == 0) { 104 | actualTraitValue = TraitLib.toString(traitValue, traitLabel.displayType); 105 | } 106 | // render the attribute as JSON 107 | return Metadata.attribute({ 108 | traitType: traitLabel.traitLabel, 109 | value: actualTraitValue, 110 | displayType: traitLabel.displayType 111 | }); 112 | } 113 | 114 | /** 115 | * @notice Given trait keys, render their labels as a properly formatted JSON array 116 | * @param traitLabelStorage Storage mapping of trait keys to TraitLabelStorage 117 | * @param keys Trait keys to render labels for 118 | */ 119 | function toLabelJson(mapping(bytes32 => TraitLabelStorage) storage traitLabelStorage, bytes32[] memory keys) 120 | internal 121 | view 122 | returns (string memory) 123 | { 124 | string[] memory result = new string[](keys.length); 125 | uint256 i; 126 | for (i; i < keys.length;) { 127 | bytes32 key = keys[i]; 128 | TraitLabel memory traitLabel = TraitLabelStorageLib.toTraitLabel(traitLabelStorage[key]); //.toTraitLabel(); 129 | result[i] = TraitLabelLib.toLabelJson(traitLabel, key); 130 | unchecked { 131 | ++i; 132 | } 133 | } 134 | return json.arrayOf(result); 135 | } 136 | } 137 | 138 | library FullTraitValueLib { 139 | /** 140 | * @notice Convert a FullTraitValue to a JSON object 141 | */ 142 | function toJson(FullTraitValue memory fullTraitValue) internal pure returns (string memory) { 143 | return json.objectOf( 144 | Solarray.strings( 145 | // TODO: is hex string appropriate here? doesn't make sense to render hashes as strings otherwise 146 | json.property("traitValue", LibString.toHexString(uint256(fullTraitValue.traitValue))), 147 | json.property("fullTraitValue", fullTraitValue.fullTraitValue) 148 | ) 149 | ); 150 | } 151 | 152 | /** 153 | * @notice Convert an array of FullTraitValues to a JSON array of objects 154 | */ 155 | function toJson(FullTraitValue[] memory fullTraitValues) internal pure returns (string memory) { 156 | string[] memory result = new string[](fullTraitValues.length); 157 | for (uint256 i = 0; i < fullTraitValues.length;) { 158 | result[i] = toJson(fullTraitValues[i]); 159 | unchecked { 160 | ++i; 161 | } 162 | } 163 | return json.arrayOf(result); 164 | } 165 | } 166 | 167 | library TraitLabelLib { 168 | error InvalidTraitValue(bytes32 traitKey, bytes32 traitValue); 169 | 170 | /** 171 | * @notice Store a TraitLabel in contract storage using SSTORE2 and return the StoredTraitLabel 172 | */ 173 | function store(TraitLabel memory self) internal returns (StoredTraitLabel) { 174 | return StoredTraitLabel.wrap(SSTORE2.write(abi.encode(self))); 175 | } 176 | 177 | /** 178 | * @notice Validate a trait value against a TraitLabel's acceptableValues 179 | * @param label TraitLabel 180 | * @param traitKey Trait key 181 | * @param traitValue Trait value 182 | */ 183 | function validateAcceptableValue(TraitLabel memory label, bytes32 traitKey, bytes32 traitValue) internal pure { 184 | string[] memory acceptableValues = label.acceptableValues; 185 | uint256 length = acceptableValues.length; 186 | DisplayType displayType = label.displayType; 187 | if (length != 0) { 188 | string memory stringValue = TraitLib.toString(traitValue, displayType); 189 | bytes32 hashedValue = keccak256(abi.encodePacked(stringValue)); 190 | for (uint256 i = 0; i < length;) { 191 | if (hashedValue == keccak256(abi.encodePacked(acceptableValues[i]))) { 192 | return; 193 | } 194 | unchecked { 195 | ++i; 196 | } 197 | } 198 | revert InvalidTraitValue(traitKey, traitValue); 199 | } 200 | } 201 | 202 | /** 203 | * @notice Convert a TraitLabel to a JSON object 204 | * @param label TraitLabel 205 | * @param traitKey Trait key for the label 206 | */ 207 | function toLabelJson(TraitLabel memory label, bytes32 traitKey) internal pure returns (string memory) { 208 | return json.objectOf( 209 | Solarray.strings( 210 | json.property("traitKey", TraitLib.asString(traitKey)), 211 | json.property( 212 | "fullTraitKey", 213 | bytes(label.fullTraitKey).length == 0 ? TraitLib.asString(traitKey) : label.fullTraitKey 214 | ), 215 | json.property("traitLabel", label.traitLabel), 216 | json.rawProperty("acceptableValues", TraitLib.toJson(label.acceptableValues)), 217 | json.rawProperty("fullTraitValues", FullTraitValueLib.toJson(label.fullTraitValues)), 218 | json.property("displayType", Metadata.toString(label.displayType)), 219 | json.rawProperty("editors", EditorsLib.toJson(label.editors)) 220 | ) 221 | ); 222 | } 223 | } 224 | 225 | library TraitLib { 226 | /** 227 | * @notice Convert a bytes32 trait value to a string 228 | * @param key The trait value to convert 229 | * @param displayType The display type of the trait value 230 | */ 231 | function toString(bytes32 key, DisplayType displayType) internal pure returns (string memory) { 232 | if ( 233 | displayType == DisplayType.Number || displayType == DisplayType.BoostNumber 234 | || displayType == DisplayType.BoostPercent 235 | ) { 236 | return LibString.toString(uint256(key)); 237 | } else { 238 | return asString(key); 239 | } 240 | } 241 | 242 | /** 243 | * @notice Convert a bytes32 to a string 244 | * @param key The bytes32 to convert 245 | */ 246 | function asString(bytes32 key) internal pure returns (string memory) { 247 | uint256 len = _bytes32StringLength(key); 248 | string memory result; 249 | ///@solidity memory-safe-assembly 250 | assembly { 251 | // assign result to free memory pointer 252 | result := mload(0x40) 253 | // increment free memory pointer by two words 254 | mstore(0x40, add(0x40, result)) 255 | // store length at result 256 | mstore(result, len) 257 | // store key at next word 258 | mstore(add(result, 0x20), key) 259 | } 260 | return result; 261 | } 262 | 263 | /** 264 | * @notice Get the "length" of a bytes32 by counting number of non-zero leading bytes 265 | */ 266 | function _bytes32StringLength(bytes32 str) internal pure returns (uint256) { 267 | // only meant to be called in a view context, so this optimizes for bytecode size over performance 268 | for (uint256 i; i < 32;) { 269 | if (str[i] == 0) { 270 | return i; 271 | } 272 | unchecked { 273 | ++i; 274 | } 275 | } 276 | return 32; 277 | } 278 | 279 | /** 280 | * @notice Convert an array of strings to a JSON array of strings 281 | */ 282 | function toJson(string[] memory acceptableValues) internal pure returns (string memory) { 283 | return json.arrayOf(json.quote(acceptableValues)); 284 | } 285 | } 286 | 287 | library EditorsLib { 288 | /** 289 | * @notice Convert an array of AllowedEditor enum values to an Editors bitmap 290 | */ 291 | function aggregate(AllowedEditor[] memory editors) internal pure returns (Editors) { 292 | uint256 editorsLength = editors.length; 293 | uint256 result; 294 | for (uint256 i = 0; i < editorsLength;) { 295 | result |= 1 << uint8(editors[i]); 296 | unchecked { 297 | ++i; 298 | } 299 | } 300 | return Editors.wrap(uint8(result)); 301 | } 302 | 303 | /** 304 | * @notice Convert an Editors bitmap to an array of AllowedEditor enum values 305 | */ 306 | function expand(Editors editors) internal pure returns (AllowedEditor[] memory allowedEditors) { 307 | uint8 _editors = Editors.unwrap(editors); 308 | if (_editors & 1 == 1) { 309 | allowedEditors = new AllowedEditor[](1); 310 | allowedEditors[0] = AllowedEditor.Anyone; 311 | return allowedEditors; 312 | } 313 | // optimistically allocate 4 slots 314 | AllowedEditor[] memory result = new AllowedEditor[](4); 315 | uint256 num; 316 | for (uint256 i = 1; i < 5;) { 317 | bool set = _editors & (1 << i) != 0; 318 | if (set) { 319 | result[num] = AllowedEditor(i); 320 | unchecked { 321 | ++num; 322 | } 323 | } 324 | unchecked { 325 | ++i; 326 | } 327 | } 328 | ///@solidity memory-safe-assembly 329 | assembly { 330 | mstore(result, num) 331 | } 332 | return result; 333 | } 334 | 335 | /** 336 | * @notice Convert an AllowedEditor enum value to its corresponding bit in an Editors bitmap 337 | */ 338 | function toBitMap(AllowedEditor editor) internal pure returns (uint8) { 339 | return uint8(1 << uint256(editor)); 340 | } 341 | 342 | /** 343 | * @notice Check if an Editors bitmap contains a given AllowedEditor 344 | */ 345 | function contains(Editors self, AllowedEditor editor) internal pure returns (bool) { 346 | return Editors.unwrap(self) & toBitMap(editor) != 0; 347 | } 348 | 349 | /** 350 | * @notice Convert an Editors bitmap to a JSON array of numbers 351 | */ 352 | function toJson(Editors editors) internal pure returns (string memory) { 353 | return toJson(expand(editors)); 354 | } 355 | 356 | /** 357 | * @notice Convert an array of AllowedEditors to a JSON array of numbers 358 | */ 359 | function toJson(AllowedEditor[] memory editors) internal pure returns (string memory) { 360 | string[] memory result = new string[](editors.length); 361 | for (uint256 i = 0; i < editors.length;) { 362 | result[i] = LibString.toString(uint8(editors[i])); 363 | unchecked { 364 | ++i; 365 | } 366 | } 367 | return json.arrayOf(result); 368 | } 369 | } 370 | 371 | library StoredTraitLabelLib { 372 | /** 373 | * @notice Check that a StoredTraitLabel is not the zero address, ie, that it exists 374 | */ 375 | function exists(StoredTraitLabel storedTraitLabel) internal pure returns (bool) { 376 | return StoredTraitLabel.unwrap(storedTraitLabel) != address(0); 377 | } 378 | 379 | /** 380 | * @notice Load a TraitLabel from contract storage using SSTORE2 381 | */ 382 | function load(StoredTraitLabel storedTraitLabel) internal view returns (TraitLabel memory) { 383 | bytes memory data = SSTORE2.read(StoredTraitLabel.unwrap(storedTraitLabel)); 384 | return abi.decode(data, (TraitLabel)); 385 | } 386 | } 387 | 388 | using EditorsLib for Editors global; 389 | using StoredTraitLabelLib for StoredTraitLabel global; 390 | -------------------------------------------------------------------------------- /src/interfaces/IERC5192.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | interface IERC5192 { 5 | /// @notice Emitted when the locking status is changed to locked. 6 | /// @dev If a token is minted and the status is locked, this event should be emitted. 7 | /// @param tokenId The identifier for a token. 8 | event Locked(uint256 tokenId); 9 | 10 | /// @notice Emitted when the locking status is changed to unlocked. 11 | /// @dev If a token is minted and the status is unlocked, this event should be emitted. 12 | /// @param tokenId The identifier for a token. 13 | event Unlocked(uint256 tokenId); 14 | 15 | /// @notice Returns the locking status of an Soulbound Token 16 | /// @dev SBTs assigned to zero address are considered invalid, and queries 17 | /// about them do throw. 18 | /// @param tokenId The identifier for an SBT. 19 | function locked(uint256 tokenId) external view returns (bool); 20 | } 21 | -------------------------------------------------------------------------------- /src/interfaces/IPreapprovalForAll.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | interface IPreapprovalForAll { 5 | /// @notice Emitted when a token contract preapproves (or revokes) all token transfers from a specific address, if 6 | /// the preapproval is configurable. This allows offchain indexers to correctly reflect token approvals 7 | /// which can later be revoked. 8 | event PreapprovalForAll(address indexed operator, bool indexed approved); 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/Constants.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | /// @dev The canonical OpenSea conduit. 5 | address constant CONDUIT = 0x1E0049783F008A0085193E00003D00cd54003c71; 6 | /// @dev `keccak256(bytes("ApprovalForAll(address,address,bool)"))`. 7 | uint256 constant _APPROVAL_FOR_ALL_EVENT_SIGNATURE = 0x17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31; 8 | /// @dev Pre-shifted and pre-masked constant. 9 | uint256 constant SOLADY_ERC721_MASTER_SLOT_SEED_MASKED = 0x0a5a2e7a00000000; 10 | /// @dev Solady ERC1155 master slot seed. 11 | uint256 constant SOLADY_ERC1155_MASTER_SLOT_SEED = 0x9a31110384e0b0c9; 12 | /// @dev `keccak256(bytes("TransferSingle(address,address,address,uint256,uint256)"))`. 13 | uint256 constant SOLADY_TRANSFER_SINGLE_EVENT_SIGNATURE = 14 | 0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62; 15 | /// @dev `keccak256(bytes("TransferBatch(address,address,address,uint256[],uint256[])"))`. 16 | uint256 constant SOLADY_TRANSFER_BATCH_EVENT_SIGNATURE = 17 | 0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb; 18 | /// @dev Solady ERC20 allowance slot seed. 19 | uint256 constant SOLADY_ERC20_ALLOWANCE_SLOT_SEED = 0x7f5e9f20; 20 | /// @dev Solady ERC20 balance slot seed. 21 | uint256 constant SOLADY_ERC20_BALANCE_SLOT_SEED = 0x87a211a2; 22 | /// @dev `keccak256(bytes("Transfer(address,address,uint256)"))`. 23 | uint256 constant SOLADY_ERC20_TRANSFER_EVENT_SIGNATURE = 24 | 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef; 25 | uint256 constant SOLADY_ERC20_APPROVAL_EVENT_SIGNATURE = 0; 26 | /// @dev Solady ERC20 nonces slot seed with signature prefix. 27 | uint256 constant SOLADY_ERC20_NONCES_SLOT_SEED_WITH_SIGNATURE_PREFIX = 0x383775081901; 28 | /// @dev `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")`. 29 | bytes32 constant SOLADY_ERC20_DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f; 30 | /// @dev Solady ERC20 version hash: `keccak256("1")`. 31 | bytes32 constant SOLADY_ERC20_VERSION_HASH = 0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6; 32 | /// @dev `keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")`. 33 | bytes32 constant SOLADY_ERC20_PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; 34 | -------------------------------------------------------------------------------- /src/onchain/Metadata.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {json} from "./json.sol"; 5 | import {LibString} from "solady/utils/LibString.sol"; 6 | import {Solarray} from "solarray/Solarray.sol"; 7 | import {Base64} from "solady/utils/Base64.sol"; 8 | 9 | enum DisplayType { 10 | String, 11 | Number, 12 | Date, 13 | BoostPercent, 14 | BoostNumber, 15 | Hidden 16 | } 17 | 18 | library Metadata { 19 | string private constant NULL = ""; 20 | 21 | using LibString for string; 22 | 23 | function attribute(string memory traitType, string memory value) internal pure returns (string memory) { 24 | return json.objectOf(Solarray.strings(json.property("trait_type", traitType), json.property("value", value))); 25 | } 26 | 27 | function attribute(string memory traitType, string memory value, DisplayType displayType) 28 | internal 29 | pure 30 | returns (string memory) 31 | { 32 | return json.objectOf( 33 | Solarray.strings( 34 | json.property("trait_type", traitType), 35 | json.property("value", value), 36 | json.property("display_type", toString(displayType)) 37 | ) 38 | ); 39 | } 40 | 41 | function toString(DisplayType displayType) internal pure returns (string memory) { 42 | if (displayType == DisplayType.String) { 43 | return "string"; 44 | } else if (displayType == DisplayType.Number) { 45 | return "number"; 46 | } else if (displayType == DisplayType.Date) { 47 | return "date"; 48 | } else if (displayType == DisplayType.BoostNumber) { 49 | return "boost_number"; 50 | } else if (displayType == DisplayType.BoostPercent) { 51 | return "boost_percent"; 52 | } else { 53 | return "hidden"; 54 | } 55 | } 56 | 57 | function dataURI(string memory dataType, string memory encoding, string memory content) 58 | internal 59 | pure 60 | returns (string memory) 61 | { 62 | return string.concat( 63 | "data:", dataType, ";", bytes(encoding).length > 0 ? string.concat(encoding, ",") : NULL, content 64 | ); 65 | } 66 | 67 | function dataURI(string memory dataType, string memory content) internal pure returns (string memory) { 68 | return dataURI(dataType, NULL, content); 69 | } 70 | 71 | function jsonDataURI(string memory content, string memory encoding) internal pure returns (string memory) { 72 | return dataURI("application/json", encoding, content); 73 | } 74 | 75 | function jsonDataURI(string memory content) internal pure returns (string memory) { 76 | return jsonDataURI(content, NULL); 77 | } 78 | 79 | function base64JsonDataURI(string memory content) internal pure returns (string memory) { 80 | return jsonDataURI(Base64.encode(bytes(content)), "base64"); 81 | } 82 | 83 | function svgDataURI(string memory content, string memory encoding, bool escape) 84 | internal 85 | pure 86 | returns (string memory) 87 | { 88 | string memory uri = dataURI("image/svg+xml", encoding, content); 89 | 90 | if (escape) { 91 | return uri.escapeJSON(); 92 | } else { 93 | return uri; 94 | } 95 | } 96 | 97 | function svgDataURI(string memory content) internal pure returns (string memory) { 98 | return svgDataURI(content, "utf8", true); 99 | } 100 | 101 | function base64SvgDataURI(string memory content) internal pure returns (string memory) { 102 | return svgDataURI(Base64.encode(bytes(content)), "base64", false); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/onchain/json.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.4; 3 | 4 | import {LibString} from "solady/utils/LibString.sol"; 5 | 6 | /** 7 | * @title JSON 8 | * @author emo.eth 9 | * @notice TODO: overrides for common types that automatically stringify 10 | */ 11 | library json { 12 | string private constant NULL = ""; 13 | 14 | using LibString for string; 15 | 16 | /** 17 | * @notice enclose a string in {braces} 18 | * Note: does not escape quotes in value 19 | * @param value string to enclose in braces 20 | * @return string of {value} 21 | */ 22 | function object(string memory value) internal pure returns (string memory) { 23 | return string.concat("{", value, "}"); 24 | } 25 | 26 | /** 27 | * @notice enclose a string in [brackets] 28 | * Note: does not escape quotes in value 29 | * @param value string to enclose in brackets 30 | * @return string of [value] 31 | */ 32 | function array(string memory value) internal pure returns (string memory) { 33 | return string.concat("[", value, "]"); 34 | } 35 | 36 | /** 37 | * @notice enclose name and value with quotes, and place a colon "between":"them". 38 | * Note: escapes quotes in name and value 39 | * @param name name of property 40 | * @param value value of property 41 | * @return string of "name":"value" 42 | */ 43 | function property(string memory name, string memory value) internal pure returns (string memory) { 44 | return string.concat('"', name.escapeJSON(), '":"', value.escapeJSON(), '"'); 45 | } 46 | 47 | /** 48 | * @notice enclose name with quotes, but not rawValue, and place a colon "between":them 49 | * Note: escapes quotes in name, but not value (which may itself be a JSON object, array, etc) 50 | * @param name name of property 51 | * @param rawValue raw value of property, which will not be enclosed in quotes 52 | * @return string of "name":value 53 | */ 54 | function rawProperty(string memory name, string memory rawValue) internal pure returns (string memory) { 55 | return string.concat('"', name.escapeJSON(), '":', rawValue); 56 | } 57 | 58 | /** 59 | * @notice comma-join an array of properties and {"enclose":"them","in":"braces"} 60 | * Note: does not escape quotes in properties, as it assumes they are already escaped 61 | * @param properties array of '"name":"value"' properties to join 62 | * @return string of {"name":"value","name":"value",...} 63 | */ 64 | function objectOf(string[] memory properties) internal pure returns (string memory) { 65 | return object(_commaJoin(properties)); 66 | } 67 | 68 | /** 69 | * @notice comma-join an array of values and enclose them [in,brackets] 70 | * Note: does not escape quotes in values, as it assumes they are already escaped 71 | * @param values array of values to join 72 | * @return string of [value,value,...] 73 | */ 74 | function arrayOf(string[] memory values) internal pure returns (string memory) { 75 | return array(_commaJoin(values)); 76 | } 77 | 78 | /** 79 | * @notice comma-join two arrays of values and [enclose,them,in,brackets] 80 | * Note: does not escape quotes in values, as it assumes they are already escaped 81 | * @param values1 first array of values to join 82 | * @param values2 second array of values to join 83 | * @return string of [values1_0,values1_1,values2_0,values2_1...] 84 | */ 85 | function arrayOf(string[] memory values1, string[] memory values2) internal pure returns (string memory) { 86 | if (values1.length == 0) { 87 | return arrayOf(values2); 88 | } else if (values2.length == 0) { 89 | return arrayOf(values1); 90 | } 91 | return array(string.concat(_commaJoin(values1), ",", _commaJoin(values2))); 92 | } 93 | 94 | /** 95 | * @notice enclose a string in double "quotes", escaping any existing quotes 96 | * @param str string to enclose in quotes 97 | * @return string of "value" 98 | */ 99 | function quote(string memory str) internal pure returns (string memory) { 100 | return string.concat('"', str.escapeJSON(), '"'); 101 | } 102 | 103 | /** 104 | * @notice enclose each string in an array in double "quotes", escaping any existing quotes 105 | * @param strs array of strings, each to escape and enclose in quotes 106 | */ 107 | function quote(string[] memory strs) internal pure returns (string[] memory) { 108 | string[] memory result = new string[](strs.length); 109 | for (uint256 i = 0; i < strs.length;) { 110 | result[i] = quote(strs[i]); 111 | unchecked { 112 | ++i; 113 | } 114 | } 115 | return result; 116 | } 117 | 118 | /** 119 | * @notice comma-join an array of strings 120 | * @param values array of strings to join 121 | * @return string of value,value,... 122 | */ 123 | function _commaJoin(string[] memory values) internal pure returns (string memory) { 124 | return _join(values, ","); 125 | } 126 | 127 | /** 128 | * @notice join two strings with a comma 129 | * @param value1 first string 130 | * @param value2 second string 131 | * @return string of value1,value2 132 | */ 133 | function _commaJoin(string memory value1, string memory value2) internal pure returns (string memory) { 134 | return string.concat(value1, ",", value2); 135 | } 136 | 137 | /** 138 | * @notice join an array of strings with a specified separator 139 | * @param values array of strings to join 140 | * @param separator separator to join with 141 | * @return string of valuevalue... 142 | */ 143 | function _join(string[] memory values, string memory separator) internal pure returns (string memory) { 144 | if (values.length == 0) { 145 | return NULL; 146 | } 147 | string memory result = values[0]; 148 | for (uint256 i = 1; i < values.length; ++i) { 149 | result = string.concat(result, separator, values[i]); 150 | } 151 | return result; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/tokens/erc721/ERC721ConduitPreapproved_Solady.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {ERC721} from "solady/tokens/ERC721.sol"; 5 | import { 6 | CONDUIT, _APPROVAL_FOR_ALL_EVENT_SIGNATURE, SOLADY_ERC721_MASTER_SLOT_SEED_MASKED 7 | } from "../../lib/Constants.sol"; 8 | import {IPreapprovalForAll} from "../../interfaces/IPreapprovalForAll.sol"; 9 | 10 | abstract contract ERC721ConduitPreapproved_Solady is ERC721, IPreapprovalForAll { 11 | constructor() { 12 | emit PreapprovalForAll(CONDUIT, true); 13 | } 14 | 15 | function transferFrom(address from, address to, uint256 id) public payable virtual override { 16 | _transfer(_by(from), from, to, id); 17 | } 18 | 19 | function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { 20 | bool approved = super.isApprovedForAll(owner, operator); 21 | return (operator == CONDUIT) ? !approved : approved; 22 | } 23 | 24 | function setApprovalForAll(address operator, bool isApproved) public virtual override { 25 | /// @solidity memory-safe-assembly 26 | assembly { 27 | // Convert to 0 or 1. 28 | isApproved := iszero(iszero(isApproved)) 29 | let isConduit := eq(operator, CONDUIT) 30 | // if isConduit, flip isApproved, otherwise leave as is 31 | let storedValue := 32 | or( 33 | // isConduit && !isApproved 34 | and(isConduit, iszero(isApproved)), 35 | // !isConduit && isApproved 36 | and(iszero(isConduit), isApproved) 37 | ) 38 | // Update the `isApproved` for (`msg.sender`, `operator`). 39 | mstore(0x1c, operator) 40 | mstore(0x08, SOLADY_ERC721_MASTER_SLOT_SEED_MASKED) 41 | mstore(0x00, caller()) 42 | sstore(keccak256(0x0c, 0x30), storedValue) 43 | // Emit the {ApprovalForAll} event. 44 | mstore(0x00, isApproved) 45 | log3(0x00, 0x20, _APPROVAL_FOR_ALL_EVENT_SIGNATURE, caller(), shr(96, shl(96, operator))) 46 | } 47 | } 48 | 49 | function _by(address from) internal view virtual returns (address result) { 50 | if (msg.sender == CONDUIT) { 51 | if (isApprovedForAll(from, CONDUIT)) { 52 | return address(0); 53 | } 54 | } 55 | return msg.sender; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/tokens/erc721/ERC721Preapproved_Solady.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {ERC721ConduitPreapproved_Solady, ERC721} from "./ERC721ConduitPreapproved_Solady.sol"; 5 | 6 | contract ERC721_Solady is ERC721ConduitPreapproved_Solady { 7 | function mint(address to, uint256 tokenId) public { 8 | _mint(to, tokenId); 9 | } 10 | 11 | function name() public pure override returns (string memory) { 12 | return "Test"; 13 | } 14 | 15 | function symbol() public pure override returns (string memory) { 16 | return "TST"; 17 | } 18 | 19 | function tokenURI(uint256) public pure override returns (string memory) { 20 | return "https://example.com"; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/StackScore.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import "forge-std/Test.sol"; 5 | import {StackScore} from "src/StackScore.sol"; 6 | import {StackScoreRenderer} from "src/StackScoreRenderer.sol"; 7 | import {ECDSA} from "solady/utils/ECDSA.sol"; 8 | import {TraitLabel, Editors, FullTraitValue, AllowedEditor, EditorsLib} from "src/dynamic-traits/lib/TraitLabelLib.sol"; 9 | import {DisplayType} from "src/onchain/Metadata.sol"; 10 | import {LibString} from "solady/utils/LibString.sol"; 11 | import {OnchainTraits} from "src/dynamic-traits/OnchainTraits.sol"; 12 | 13 | // Fee recipient contract is a hypothetical treasury. 14 | contract Treasury { 15 | event Received(address indexed sender, uint256 value); 16 | /// @notice Received event. 17 | receive() external payable { 18 | emit Received(msg.sender, msg.value); 19 | } 20 | } 21 | 22 | contract StackScoreTest is Test { 23 | /// @notice StackScore token contract. 24 | StackScore token; 25 | /// @notice StackScoreRenderer contract. 26 | StackScoreRenderer renderer; 27 | 28 | /// @notice Address of the public key signer. 29 | address public signer; 30 | /// @notice Public key of the signer. 31 | uint256 public signerPk; 32 | /// @notice Address of the owner. 33 | address public owner; 34 | /// @notice Address of test user 1. 35 | address public user1; 36 | /// @notice Address of test user 2. 37 | address public user2; 38 | /// @notice Address of the mint fee recipient. 39 | address public mintFeeRecipient; 40 | 41 | /// @notice Unauthorized error 42 | /// @dev This error is thrown when a user is not authorized to perform an action. 43 | error Unauthorized(); 44 | 45 | /// @notice Test setup function. 46 | /// @dev This function is called before each test. 47 | /// @dev It initializes the token contract and sets up the test environment. 48 | function setUp() public { 49 | (address alice, uint256 alicePk) = makeAddrAndKey("alice"); 50 | signer = alice; 51 | signerPk = alicePk; 52 | owner = address(this); 53 | user1 = address(0x1); 54 | user2 = address(0x2); 55 | 56 | Treasury treasury = new Treasury(); 57 | mintFeeRecipient = address(treasury); 58 | 59 | token = new StackScore(address(this)); 60 | renderer = new StackScoreRenderer(); 61 | token.setRenderer(address(renderer)); 62 | token.setSigner(signer); 63 | token.setMintFeeRecipient(mintFeeRecipient); 64 | token.transferOwnership(signer); 65 | _setLabel(); 66 | 67 | vm.deal(user1, 1 ether); 68 | vm.deal(user2, 1 ether); 69 | } 70 | 71 | function testInitialState() public view { 72 | assertEq(token.name(), "Stack Score"); 73 | assertEq(token.symbol(), "STACK_SCORE"); 74 | assertEq(token.version(), "1"); 75 | assertEq(token.signer(), signer); 76 | assertEq(token.mintFee(), 0.001 ether); 77 | assertEq(token.getCurrentId(), 0); 78 | assertEq(token.getRenderer(), address(renderer)); 79 | assertEq(token.mintFeeRecipient(), mintFeeRecipient); 80 | } 81 | 82 | function testMint() public { 83 | vm.prank(user1); 84 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 85 | assertEq(tokenId, 1); 86 | assertEq(token.ownerOf(1), user1); 87 | assertEq(token.balanceOf(user1), 1); 88 | assertEq(token.getCurrentId(), 1); 89 | } 90 | 91 | function testMintEmitsEvent() public { 92 | vm.prank(user1); 93 | vm.expectEmit(true, true, false, true); 94 | emit StackScore.Minted(user1, 1); 95 | token.mint{value: 0.001 ether}(user1); 96 | } 97 | 98 | function testMintFeeTransfer() public { 99 | uint256 initialBalance = mintFeeRecipient.balance; 100 | vm.prank(user1); 101 | token.mint{value: 0.001 ether}(user1); 102 | assertEq(mintFeeRecipient.balance, initialBalance + 0.001 ether); 103 | } 104 | 105 | function testMintWithInsufficientFee() public { 106 | vm.prank(user1); 107 | vm.expectRevert(StackScore.InsufficientFee.selector); 108 | token.mint{value: 0.0009 ether}(user1); 109 | } 110 | 111 | function testMintOnlyOneTokenPerAddress() public { 112 | vm.startPrank(user1); 113 | token.mint{value: 0.001 ether}(user1); 114 | vm.expectRevert(StackScore.OneTokenPerAddress.selector); 115 | token.mint{value: 0.001 ether}(user1); 116 | vm.stopPrank(); 117 | } 118 | 119 | function testMintWithZeroScore() public { 120 | vm.prank(user1); 121 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 122 | assertEq(token.getScore(user1), 0); 123 | } 124 | 125 | function testMintWithScoreAndZeroScore() public { 126 | uint256 score = 0; 127 | uint256 timestamp = block.timestamp; 128 | bytes memory signature = signScore(user1, score, timestamp); 129 | 130 | vm.prank(user1); 131 | uint256 tokenId = token.mintWithScore{value: 0.001 ether}(user1, score, timestamp, signature); 132 | assertEq(token.getScore(user1), score); 133 | } 134 | 135 | function testMintWithScore() public { 136 | uint256 score = 245; 137 | uint256 timestamp = block.timestamp; 138 | bytes memory signature = signScore(user1, score, timestamp); 139 | 140 | vm.prank(user1); 141 | uint256 tokenId = token.mintWithScore{value: 0.001 ether}(user1, score, timestamp, signature); 142 | 143 | assertEq(tokenId, 1); 144 | assertEq(token.getScore(user1), score); 145 | } 146 | 147 | function testMintWithScoreAndPalette() public { 148 | uint256 score = 245; 149 | uint256 timestamp = block.timestamp; 150 | uint256 palette = 3; 151 | bytes memory signature = signScore(user1, score, timestamp); 152 | 153 | vm.prank(user1); 154 | uint256 tokenId = token.mintWithScoreAndPalette{value: 0.001 ether}(user1, score, timestamp, palette, signature); 155 | 156 | assertEq(tokenId, 1); 157 | assertEq(token.getScore(user1), score); 158 | assertEq(token.getPaletteIndex(tokenId), palette); 159 | } 160 | 161 | function testUpdateScore() public { 162 | vm.prank(user1); 163 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 164 | 165 | uint256 newScore = 300; 166 | uint256 timestamp = block.timestamp; 167 | bytes memory signature = signScore(user1, newScore, timestamp); 168 | 169 | vm.prank(user1); 170 | vm.expectEmit(true, true, true, true); 171 | emit StackScore.ScoreUpdated(user1, tokenId, 0, newScore); 172 | token.updateScore(tokenId, newScore, timestamp, signature); 173 | 174 | assertEq(token.getScore(user1), newScore); 175 | } 176 | 177 | function testGetScore() public { 178 | vm.prank(user1); 179 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 180 | 181 | uint256 score = 245; 182 | uint256 timestamp = block.timestamp; 183 | bytes memory signature = signScore(user1, score, timestamp); 184 | 185 | vm.prank(user1); 186 | token.updateScore(tokenId, score, timestamp, signature); 187 | 188 | assertEq(token.getScore(user1), score); 189 | } 190 | 191 | function testGetScoreAndUpdatedAt() public { 192 | vm.prank(user1); 193 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 194 | 195 | uint256 scoreInput = 245; 196 | uint256 timestamp = block.timestamp; 197 | bytes memory signature = signScore(user1, scoreInput, timestamp); 198 | 199 | vm.prank(user1); 200 | token.updateScore(tokenId, scoreInput, timestamp, signature); 201 | 202 | ( 203 | uint256 scoreResult, 204 | uint256 updatedAt 205 | ) = token.getScoreAndLastUpdated(user1); 206 | 207 | assertEq(scoreResult, scoreInput); 208 | assertEq(updatedAt, timestamp); 209 | } 210 | 211 | function testUpdateScoreWithInvalidSignature() public { 212 | vm.prank(user1); 213 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 214 | 215 | uint256 newScore = 300; 216 | uint256 timestamp = block.timestamp; 217 | bytes memory signature = signScore(user2, newScore, timestamp); // Using wrong address 218 | 219 | vm.prank(user1); 220 | vm.expectRevert(StackScore.InvalidSignature.selector); 221 | token.updateScore(tokenId, newScore, timestamp, signature); 222 | } 223 | 224 | function testUpdateScoreWithOldTimestamp() public { 225 | vm.prank(user1); 226 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 227 | 228 | uint256 oldScore = 200; 229 | uint256 oldTimestamp = block.timestamp; 230 | bytes memory oldSignature = signScore(user1, oldScore, oldTimestamp); 231 | 232 | vm.prank(user1); 233 | token.updateScore(tokenId, oldScore, oldTimestamp, oldSignature); 234 | 235 | uint256 newScore = 300; 236 | uint256 newTimestamp = oldTimestamp - 1; // Using an older timestamp 237 | bytes memory newSignature = signScore(user1, newScore, newTimestamp); 238 | 239 | vm.prank(user1); 240 | vm.expectRevert(StackScore.TimestampTooOld.selector); 241 | token.updateScore(tokenId, newScore, newTimestamp, newSignature); 242 | } 243 | 244 | function testUpdatePalette() public { 245 | vm.prank(user1); 246 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 247 | 248 | uint256 newPalette = 5; 249 | vm.prank(user1); 250 | token.updatePalette(tokenId, newPalette); 251 | 252 | assertEq(token.getPaletteIndex(tokenId), newPalette); 253 | } 254 | 255 | function testUpdatePaletteOnlyOwner() public { 256 | vm.prank(user1); 257 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 258 | 259 | uint256 newPalette = 5; 260 | vm.prank(user2); 261 | vm.expectRevert(StackScore.OnlyTokenOwner.selector); 262 | token.updatePalette(tokenId, newPalette); 263 | } 264 | 265 | function testSoulbound() public { 266 | vm.prank(user1); 267 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 268 | 269 | vm.prank(user1); 270 | vm.expectRevert(abi.encodeWithSelector(StackScore.TokenLocked.selector, tokenId)); 271 | token.transferFrom(user1, user2, tokenId); 272 | } 273 | 274 | function testSetRendererOnlyOwner() public { 275 | address newRenderer = address(0x123); 276 | vm.prank(signer); 277 | token.setRenderer(newRenderer); 278 | assertEq(token.getRenderer(), newRenderer); 279 | 280 | vm.prank(user1); 281 | vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector)); 282 | token.setRenderer(address(0x456)); 283 | } 284 | 285 | function testSetSignerOnlyOwner() public { 286 | address newSigner = address(0x123); 287 | vm.prank(signer); 288 | token.setSigner(newSigner); 289 | assertEq(token.signer(), newSigner); 290 | 291 | vm.prank(user1); 292 | vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector)); 293 | token.setSigner(address(0x456)); 294 | } 295 | 296 | function testSetMintFeeOnlyOwner() public { 297 | uint256 newFee = 0.002 ether; 298 | vm.prank(signer); 299 | vm.expectEmit(true, true, false, true); 300 | emit StackScore.MintFeeUpdated(0.001 ether, newFee); 301 | token.setMintFee(newFee); 302 | assertEq(token.mintFee(), newFee); 303 | 304 | vm.prank(user1); 305 | vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector)); 306 | token.setMintFee(0.003 ether); 307 | } 308 | 309 | function testSetMintFeeRecipientOnlyOwner() public { 310 | address newRecipient = address(0x123); 311 | vm.prank(signer); 312 | token.setMintFeeRecipient(newRecipient); 313 | assertEq(token.mintFeeRecipient(), newRecipient); 314 | 315 | vm.prank(user1); 316 | vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector)); 317 | token.setMintFeeRecipient(address(0x456)); 318 | } 319 | 320 | function testTokenURI() public { 321 | vm.prank(user1); 322 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 323 | 324 | string memory uri = token.tokenURI(tokenId); 325 | assertTrue(bytes(uri).length > 0); 326 | 327 | assertTrue(LibString.startsWith(uri, "{\"name\":\"Stack Score\"")); 328 | assertTrue(LibString.contains(uri, "data:image/svg+xml;base64,")); 329 | } 330 | 331 | function testLocked() public { 332 | vm.prank(user1); 333 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 334 | 335 | assertTrue(token.locked(tokenId)); 336 | } 337 | 338 | function testMintWithReferral() public { 339 | address referrer = address(0x4); 340 | uint256 initialMintFeeRecipientBalance = mintFeeRecipient.balance; 341 | uint256 initialReferrerBalance = referrer.balance; 342 | 343 | vm.prank(user1); 344 | uint256 tokenId = token.mintWithReferral{value: 0.001 ether}(user1, referrer); 345 | 346 | assertEq(tokenId, 1); 347 | assertEq(token.ownerOf(1), user1); 348 | assertEq(token.balanceOf(user1), 1); 349 | assertEq(token.getCurrentId(), 1); 350 | 351 | // Check referral fee distribution 352 | uint256 referralFee = (0.001 ether * token.referralBps()) / 10000; 353 | assertEq(referrer.balance, initialReferrerBalance + referralFee); 354 | assertEq(mintFeeRecipient.balance, initialMintFeeRecipientBalance + (0.001 ether - referralFee)); 355 | } 356 | 357 | function testMintWithScoreAndReferral() public { 358 | address referrer = address(0x4); 359 | uint256 score = 245; 360 | uint256 timestamp = block.timestamp; 361 | bytes memory signature = signScore(user1, score, timestamp); 362 | 363 | uint256 initialMintFeeRecipientBalance = mintFeeRecipient.balance; 364 | uint256 initialReferrerBalance = referrer.balance; 365 | 366 | vm.prank(user1); 367 | uint256 tokenId = token.mintWithScoreAndReferral{value: 0.001 ether}(user1, score, timestamp, referrer, signature); 368 | 369 | assertEq(tokenId, 1); 370 | assertEq(token.ownerOf(1), user1); 371 | assertEq(token.balanceOf(user1), 1); 372 | assertEq(token.getCurrentId(), 1); 373 | assertEq(token.getScore(user1), score); 374 | 375 | // Check referral fee distribution 376 | uint256 referralFee = (0.001 ether * token.referralBps()) / 10000; 377 | assertEq(referrer.balance, initialReferrerBalance + referralFee); 378 | assertEq(mintFeeRecipient.balance, initialMintFeeRecipientBalance + (0.001 ether - referralFee)); 379 | } 380 | 381 | function testMintWithScoreAndReferralAndPalette() public { 382 | address referrer = address(0x4); 383 | uint256 score = 245; 384 | uint256 timestamp = block.timestamp; 385 | uint256 palette = 3; 386 | bytes memory signature = signScore(user1, score, timestamp); 387 | 388 | uint256 initialMintFeeRecipientBalance = mintFeeRecipient.balance; 389 | uint256 initialReferrerBalance = referrer.balance; 390 | 391 | vm.prank(user1); 392 | uint256 tokenId = token.mintWithScoreAndReferralAndPalette{value: 0.001 ether}(user1, score, timestamp, referrer, palette, signature); 393 | 394 | assertEq(tokenId, 1); 395 | assertEq(token.ownerOf(1), user1); 396 | assertEq(token.balanceOf(user1), 1); 397 | assertEq(token.getCurrentId(), 1); 398 | assertEq(token.getScore(user1), score); 399 | assertEq(token.getPaletteIndex(tokenId), palette); 400 | 401 | // Check referral fee distribution 402 | uint256 referralFee = (0.001 ether * token.referralBps()) / 10000; 403 | assertEq(referrer.balance, initialReferrerBalance + referralFee); 404 | assertEq(mintFeeRecipient.balance, initialMintFeeRecipientBalance + (0.001 ether - referralFee)); 405 | } 406 | 407 | function testMintWithReferralInsufficientFee() public { 408 | address referrer = address(0x4); 409 | vm.prank(user1); 410 | vm.expectRevert(StackScore.InsufficientFee.selector); 411 | token.mintWithReferral{value: 0.0009 ether}(user1, referrer); 412 | } 413 | 414 | function testSetReferralBpsOnlyOwner() public { 415 | uint256 newReferralBps = 100; // 1% 416 | vm.prank(signer); 417 | token.setReferralBps(newReferralBps); 418 | assertEq(token.referralBps(), newReferralBps); 419 | 420 | vm.prank(user1); 421 | vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector)); 422 | token.setReferralBps(200); 423 | } 424 | 425 | function testReferralFeesWithDifferentBPS() public { 426 | vm.startPrank(signer); 427 | token.setReferralBps(100); // 1% 428 | vm.stopPrank(); 429 | 430 | address referrer = address(0x4); 431 | uint256 initialMintFeeRecipientBalance = mintFeeRecipient.balance; 432 | uint256 initialReferrerBalance = referrer.balance; 433 | 434 | vm.prank(user1); 435 | token.mintWithReferral{value: 0.001 ether}(user1, referrer); 436 | 437 | uint256 referralFee = (0.001 ether * 100) / 10000; // 1% of 0.001 ether 438 | assertEq(referrer.balance, initialReferrerBalance + referralFee); 439 | assertEq(mintFeeRecipient.balance, initialMintFeeRecipientBalance + (0.001 ether - referralFee)); 440 | } 441 | 442 | function testReferralFeesWithZeroBPS() public { 443 | vm.startPrank(signer); 444 | token.setReferralBps(0); // 0% 445 | vm.stopPrank(); 446 | 447 | address referrer = address(0x4); 448 | uint256 initialMintFeeRecipientBalance = mintFeeRecipient.balance; 449 | uint256 initialReferrerBalance = referrer.balance; 450 | 451 | vm.prank(user1); 452 | token.mintWithReferral{value: 0.001 ether}(user1, referrer); 453 | 454 | assertEq(referrer.balance, initialReferrerBalance); // Referrer should receive nothing 455 | assertEq(mintFeeRecipient.balance, initialMintFeeRecipientBalance + 0.001 ether); // MintFeeRecipient should receive full amount 456 | } 457 | 458 | function testReferralFeesWithMaxBPS() public { 459 | vm.startPrank(signer); 460 | token.setReferralBps(10000); // 100% 461 | vm.stopPrank(); 462 | 463 | address referrer = address(0x4); 464 | uint256 initialMintFeeRecipientBalance = mintFeeRecipient.balance; 465 | uint256 initialReferrerBalance = referrer.balance; 466 | 467 | vm.prank(user1); 468 | token.mintWithReferral{value: 0.001 ether}(user1, referrer); 469 | 470 | assertEq(referrer.balance, initialReferrerBalance + 0.001 ether); // Referrer should receive full amount 471 | assertEq(mintFeeRecipient.balance, initialMintFeeRecipientBalance); // MintFeeRecipient should receive nothing 472 | } 473 | 474 | function testSignatureReplayProtection() public { 475 | vm.prank(user1); 476 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 477 | 478 | uint256 newScore = 300; 479 | uint256 timestamp = block.timestamp; 480 | bytes memory signature = signScore(user1, newScore, timestamp); 481 | 482 | vm.prank(user1); 483 | token.updateScore(tokenId, newScore, timestamp, signature); 484 | 485 | // Attempt to use the same signature again 486 | vm.prank(user1); 487 | vm.expectRevert(StackScore.SignatureAlreadyUsed.selector); 488 | token.updateScore(tokenId, newScore, timestamp, signature); 489 | } 490 | 491 | function testDifferentSignatures() public { 492 | vm.prank(user1); 493 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 494 | 495 | uint256 score = 300; 496 | uint256 newScore = 301; 497 | uint256 timestamp = block.timestamp; 498 | uint256 timestamp2 = timestamp + 1; 499 | bytes memory signature1 = signScore(user1, score, timestamp); 500 | bytes memory signature2 = signScore(user1, newScore, timestamp2); 501 | 502 | assertTrue(keccak256(signature1) != keccak256(signature2)); 503 | 504 | vm.prank(user1); 505 | token.updateScore(tokenId, score, timestamp, signature1); 506 | 507 | // Second signature should still be valid 508 | vm.prank(user1); 509 | token.updateScore(tokenId, newScore, timestamp2, signature2); 510 | } 511 | 512 | function testTokenURIComponents() public { 513 | vm.prank(user1); 514 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 515 | 516 | string memory uri = token.tokenURI(tokenId); 517 | 518 | assertTrue(LibString.contains(uri, "\"name\":\"Stack Score\"")); 519 | assertTrue(LibString.contains(uri, "\"description\":\"A dynamic, onchain, soulbound reputation score\"")); 520 | assertTrue(LibString.contains(uri, "\"image\":\"data:image/svg+xml;base64,")); 521 | assertTrue(LibString.contains(uri, "\"attributes\":[")); 522 | } 523 | 524 | function testTokenURIUpdatesWithScore() public { 525 | vm.prank(user1); 526 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 527 | 528 | string memory initialUri = token.tokenURI(tokenId); 529 | 530 | uint256 newScore = 300; 531 | uint256 timestamp = block.timestamp; 532 | bytes memory signature = signScore(user1, newScore, timestamp); 533 | 534 | vm.prank(user1); 535 | token.updateScore(tokenId, newScore, timestamp, signature); 536 | 537 | string memory updatedUri = token.tokenURI(tokenId); 538 | 539 | assertTrue(keccak256(bytes(initialUri)) != keccak256(bytes(updatedUri))); 540 | assertTrue(LibString.contains(updatedUri, LibString.toString(newScore))); 541 | } 542 | 543 | function testErrorOnTransferAttempt() public { 544 | vm.prank(user1); 545 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 546 | 547 | vm.prank(user1); 548 | vm.expectRevert(abi.encodeWithSelector(StackScore.TokenLocked.selector, tokenId)); 549 | token.transferFrom(user1, user2, tokenId); 550 | } 551 | 552 | function testErrorOnInvalidTimestamp() public { 553 | vm.prank(user1); 554 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 555 | 556 | uint256 oldScore = 200; 557 | uint256 oldTimestamp = block.timestamp; 558 | bytes memory oldSignature = signScore(user1, oldScore, oldTimestamp); 559 | 560 | vm.prank(user1); 561 | token.updateScore(tokenId, oldScore, oldTimestamp, oldSignature); 562 | 563 | uint256 newScore = 300; 564 | uint256 newTimestamp = oldTimestamp - 1; // Using an older timestamp 565 | bytes memory newSignature = signScore(user1, newScore, newTimestamp); 566 | 567 | vm.prank(user1); 568 | vm.expectRevert(StackScore.TimestampTooOld.selector); 569 | token.updateScore(tokenId, newScore, newTimestamp, newSignature); 570 | } 571 | 572 | function testErrorOnUnauthorizedPaletteUpdate() public { 573 | vm.prank(user1); 574 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 575 | 576 | vm.prank(user2); 577 | vm.expectRevert(StackScore.OnlyTokenOwner.selector); 578 | token.updatePalette(tokenId, 5); 579 | } 580 | 581 | function testErrorOnMintWithInsufficientFee() public { 582 | vm.prank(user1); 583 | vm.expectRevert(StackScore.InsufficientFee.selector); 584 | token.mint{value: 0.0009 ether}(user1); 585 | } 586 | 587 | function testErrorOnSecondMintAttempt() public { 588 | vm.startPrank(user1); 589 | token.mint{value: 0.001 ether}(user1); 590 | vm.expectRevert(StackScore.OneTokenPerAddress.selector); 591 | token.mint{value: 0.001 ether}(user1); 592 | vm.stopPrank(); 593 | } 594 | 595 | function testErrorSetTraitDirectly() public { 596 | vm.startPrank(user1); 597 | uint256 tokenId = token.mint{value: 0.001 ether}(user1); 598 | 599 | vm.expectRevert(OnchainTraits.InsufficientPrivilege.selector); 600 | token.setTrait(tokenId, "score", bytes32(uint256(100))); 601 | 602 | vm.expectRevert(OnchainTraits.InsufficientPrivilege.selector); 603 | token.setTrait(tokenId, "paletteIndex", bytes32(uint256(4))); 604 | vm.stopPrank(); 605 | } 606 | 607 | // Helper functions 608 | 609 | function signScore(address account, uint256 score, uint256 timestamp) internal view returns (bytes memory) { 610 | bytes32 messageHash = keccak256(abi.encodePacked(account, score, timestamp)); 611 | bytes32 hash = ECDSA.toEthSignedMessageHash(messageHash); 612 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, hash); 613 | return abi.encodePacked(r, s, v); 614 | } 615 | 616 | function _setLabel() internal { 617 | vm.startPrank(signer); 618 | 619 | TraitLabel memory scoreLabel = TraitLabel({ 620 | fullTraitKey: "score", 621 | traitLabel: "Score", 622 | acceptableValues: new string[](0), 623 | fullTraitValues: new FullTraitValue[](0), 624 | displayType: DisplayType.Number, 625 | editors: Editors.wrap(EditorsLib.toBitMap(AllowedEditor.Self)), 626 | required: true 627 | }); 628 | token.setTraitLabel(bytes32("score"), scoreLabel); 629 | 630 | TraitLabel memory paletteLabel = TraitLabel({ 631 | fullTraitKey: "paletteIndex", 632 | traitLabel: "PaletteIndex", 633 | acceptableValues: new string[](0), 634 | fullTraitValues: new FullTraitValue[](0), 635 | displayType: DisplayType.Number, 636 | editors: Editors.wrap(EditorsLib.toBitMap(AllowedEditor.Self)), 637 | required: true 638 | }); 639 | token.setTraitLabel(bytes32("paletteIndex"), paletteLabel); 640 | 641 | TraitLabel memory updatedLabel = TraitLabel({ 642 | fullTraitKey: "updatedAt", 643 | traitLabel: "UpdatedAt", 644 | acceptableValues: new string[](0), 645 | fullTraitValues: new FullTraitValue[](0), 646 | displayType: DisplayType.Number, 647 | editors: Editors.wrap(EditorsLib.toBitMap(AllowedEditor.Self)), 648 | required: true 649 | }); 650 | token.setTraitLabel(bytes32("updatedAt"), updatedLabel); 651 | 652 | vm.stopPrank(); 653 | } 654 | } 655 | -------------------------------------------------------------------------------- /test/StackScoreRenderer.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import "forge-std/Test.sol"; 5 | import {StackScoreRenderer} from "src/StackScoreRenderer.sol"; 6 | import {Metadata} from "../src/onchain/Metadata.sol"; 7 | import {LibString} from "solady/utils/LibString.sol"; 8 | 9 | contract StackScoreRendererTest is Test { 10 | StackScoreRenderer renderer; 11 | address testAddress = address(0x1234567890123456789012345678901234567890); 12 | 13 | function setUp() public { 14 | renderer = new StackScoreRenderer(); 15 | } 16 | 17 | function testGetTimestampString() public { 18 | string memory timestamp = renderer.getTimestampString(1728125991); 19 | assertEq(timestamp, "OCT 5 2024 10:59 UTC"); 20 | } 21 | 22 | function testGetSVG() public { 23 | string memory svg = renderer.getSVG(100, testAddress, 0, 1728125991); 24 | assertTrue(bytes(svg).length > 0); 25 | assertTrue(LibString.contains(svg, "100")); 26 | assertTrue(LibString.contains(svg, LibString.toHexString(testAddress))); 27 | assertTrue(LibString.contains(svg, "OCT 5 2024 10:59 UTC")); 28 | } 29 | 30 | function testGetSVGAsDataURI() public { 31 | string memory dataURI = Metadata.base64SvgDataURI( 32 | renderer.getSVG(100, testAddress, 6, 1728125991) 33 | ); 34 | assertTrue(LibString.startsWith(dataURI, "data:image/svg+xml;base64,")); 35 | } 36 | 37 | function testGetColorAsHexString() public { 38 | string memory color = renderer.getColorAsHexString(0, 0); 39 | assertEq(color, "4f4c42"); 40 | } 41 | 42 | function testDifferentScores() public { 43 | string memory svg1 = renderer.getSVG(100, testAddress, 0, 1728125991); 44 | string memory svg2 = renderer.getSVG(200, testAddress, 0, 1728125991); 45 | assertTrue(LibString.contains(svg1, "100")); 46 | assertTrue(LibString.contains(svg2, "200")); 47 | } 48 | 49 | function testDifferentAddresses() public { 50 | address testAddress2 = address(0x9876543210987654321098765432109876543210); 51 | string memory svg1 = renderer.getSVG(100, testAddress, 0, 1728125991); 52 | string memory svg2 = renderer.getSVG(100, testAddress2, 0, 1728125991); 53 | assertTrue(LibString.contains(svg1, LibString.toHexString(testAddress))); 54 | assertTrue(LibString.contains(svg2, LibString.toHexString(testAddress2))); 55 | } 56 | 57 | function testDifferentPalettes() public { 58 | string memory svg1 = renderer.getSVG(100, testAddress, 0, 1728125991); 59 | string memory svg2 = renderer.getSVG(100, testAddress, 1, 1728125991); 60 | assertTrue(LibString.contains(svg1, renderer.getColorAsHexString(0, 0))); 61 | assertTrue(LibString.contains(svg2, renderer.getColorAsHexString(1, 0))); 62 | } 63 | 64 | function testDifferentTimestamps() public { 65 | string memory svg1 = renderer.getSVG(100, testAddress, 0, 1728125991); 66 | string memory svg2 = renderer.getSVG(100, testAddress, 0, 1728212391); 67 | assertTrue(LibString.contains(svg1, "OCT 5 2024 10:59 UTC")); 68 | assertTrue(LibString.contains(svg2, "OCT 6 2024 10:59 UTC")); 69 | } 70 | 71 | function testInvalidPaletteIndex() public { 72 | vm.expectRevert(); 73 | renderer.getSVG(100, testAddress, 11, 1728125991); 74 | } 75 | 76 | function testFuzzScores(uint256 score) public { 77 | string memory svg = renderer.getSVG(score, testAddress, 0, 1728125991); 78 | assertTrue(LibString.contains(svg, LibString.toString(score))); 79 | } 80 | 81 | function testFuzzAddresses(address addr) public { 82 | string memory svg = renderer.getSVG(100, addr, 0, 1728125991); 83 | assertTrue(LibString.contains(svg, LibString.toHexString(addr))); 84 | } 85 | 86 | function testFuzzPalettes(uint256 paletteIndex) public { 87 | vm.assume(paletteIndex < 11); 88 | string memory svg = renderer.getSVG(100, testAddress, paletteIndex, 1728125991); 89 | assertTrue(LibString.contains(svg, renderer.getColorAsHexString(paletteIndex, 0))); 90 | } 91 | 92 | function testFuzzTimestamps(uint256 timestamp) public { 93 | string memory svg = renderer.getSVG(100, testAddress, 0, timestamp); 94 | string memory timestampString = renderer.getTimestampString(timestamp); 95 | assertTrue(LibString.contains(svg, timestampString)); 96 | } 97 | } 98 | --------------------------------------------------------------------------------