├── .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 | ''
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 |
--------------------------------------------------------------------------------