├── .husky └── pre-commit ├── .prettierignore ├── images └── overview.png ├── .gitmodules ├── remappings.txt ├── src ├── misc │ ├── EmptyContract.sol │ └── SBT.sol ├── Common.sol ├── badge │ ├── extensions │ │ ├── ScrollBadgeEligibilityCheck.sol │ │ ├── ScrollBadgeNonRevocable.sol │ │ ├── IScrollBadgeUpgradeable.sol │ │ ├── ScrollBadgeSingleton.sol │ │ ├── ScrollBadgeSelfAttest.sol │ │ ├── ScrollBadgeDefaultURI.sol │ │ ├── ScrollBadgeNoExpiry.sol │ │ ├── ScrollBadgeCustomPayload.sol │ │ ├── ScrollBadgeAccessControl.sol │ │ └── ScrollBadgeSBT.sol │ ├── examples │ │ ├── ScrollBadgeSimple.sol │ │ ├── ScrollBadgeWhale.sol │ │ ├── ScrollBadgePermissionless.sol │ │ ├── ScrollBadgeLevels.sol │ │ ├── EthereumYearBadge.sol │ │ ├── ScrollBadgeTokenOwner.sol │ │ ├── ScrollBadgePowerRank.sol │ │ └── SCRHoldingBadge.sol │ └── ScrollBadge.sol ├── interfaces │ ├── IScrollSelfAttestationBadge.sol │ ├── IProfile.sol │ ├── IScrollBadge.sol │ ├── IScrollBadgeResolver.sol │ └── IProfileRegistry.sol ├── Errors.sol ├── resolver │ ├── ScrollBadgeResolverWhitelist.sol │ └── ScrollBadgeResolver.sol ├── AttesterProxy.sol └── profile │ └── ProfileRegistry.sol ├── .gitignore ├── foundry.toml ├── examples ├── src │ ├── badges.js │ ├── decode-error.js │ ├── attest-simple.js │ ├── referral.js │ ├── lib.js │ └── attest-server.js ├── package.json ├── test-env.sh └── .env.example ├── .github └── workflows │ ├── test.yml │ └── contracts.yml ├── package.json ├── .prettierrc ├── LICENSE ├── test ├── ScrollBadgeDefaultURI.t.sol ├── ScrollBadgeSelfAttest.t.sol ├── ScrollBadgeSingleton.t.sol ├── EthereumYearBadge.t.sol ├── ScrollBadgeCustomPayload.t.sol ├── ScrollBadgeNoExpiry.t.sol ├── ScrollBadgeSBT.t.sol ├── ScrollBadgeAccessControl.t.sol ├── ScrollBadgeNonRevocable.t.sol ├── ScrollBadgeTestBase.sol ├── ScrollBadge.t.sol ├── ScrollBadgeResolver.t.sol ├── ProfileRegistry.t.sol ├── SCRHoldingBadge.t.sol └── ScrollBadgeInheritanceChain.t.sol ├── docs ├── deployments.md ├── official-badges │ ├── ethereum-year-badge.md │ └── scroll-origins-badge.md ├── README.md └── canvas-interaction-guide.md ├── README.md └── script ├── DeployCanvasContracts.sol ├── DeployTestContracts.sol ├── DeployCanvasTestBadgeContracts.sol └── CheckBadge.s.sol /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | forge fmt --root . 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage* 5 | gasReporterOutput.json 6 | -------------------------------------------------------------------------------- /images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scroll-tech/canvas-contracts/HEAD/images/overview.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | @eas/=node_modules/@ethereum-attestation-service/eas-contracts/ 2 | @openzeppelin/=node_modules/@openzeppelin 3 | solmate/=node_modules/solmate/src 4 | -------------------------------------------------------------------------------- /src/misc/EmptyContract.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.19; 3 | 4 | // solhint-disable no-empty-blocks 5 | 6 | contract EmptyContract {} 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS files 2 | .DS_Store 3 | 4 | # Compiler files 5 | cache/ 6 | out/ 7 | 8 | # Ignores development broadcast logs 9 | !/broadcast 10 | /broadcast/*/31337/ 11 | /broadcast/**/dry-run/ 12 | 13 | # Dotenv file 14 | .env 15 | 16 | # Node files 17 | node_modules 18 | 19 | # vscode 20 | .vscode -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | solc = "0.8.19" 6 | 7 | [fmt] 8 | line_length = 120 9 | tab_width = 4 10 | bracket_spacing = false 11 | int_types = "long" 12 | multiline_func_header = "attributes_first" 13 | quote_style = "double" 14 | number_underscore = "thousands" 15 | override_spacing = true 16 | wrap_comments = true 17 | ignore = [] -------------------------------------------------------------------------------- /examples/src/badges.js: -------------------------------------------------------------------------------- 1 | import { normalizeAddress } from './lib.js'; 2 | 3 | export const badges = {}; 4 | 5 | badges[normalizeAddress(process.env.SIMPLE_BADGE_CONTRACT_ADDRESS)] = { 6 | name: 'Simple Badge', 7 | address: normalizeAddress(process.env.SIMPLE_BADGE_CONTRACT_ADDRESS), 8 | proxy: normalizeAddress(process.env.SIMPLE_BADGE_ATTESTER_PROXY_CONTRACT_ADDRESS), 9 | isEligible: async (recipient) => true, 10 | createPayload: async (recipient) => '0x', 11 | }; 12 | -------------------------------------------------------------------------------- /src/Common.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | uint256 constant MAX_ATTACHED_BADGE_NUM = 48; 6 | 7 | string constant SCROLL_BADGE_SCHEMA = "address badge, bytes payload"; 8 | 9 | function decodeBadgeData(bytes memory data) pure returns (address, bytes memory) { 10 | return abi.decode(data, (address, bytes)); 11 | } 12 | 13 | function encodeBadgeData(address badge, bytes memory payload) pure returns (bytes memory) { 14 | return abi.encode(badge, payload); 15 | } 16 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scroll-canvas-examples", 3 | "version": "0.0.1", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "test-env": "./test-env.sh", 8 | "simple": "node src/attest-simple.js", 9 | "server": "node src/attest-server.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@ethereum-attestation-service/eas-sdk": "^1.4.2", 16 | "dotenv": "^16.4.3", 17 | "ethers": "^6.11.0", 18 | "express": "^4.18.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/test-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -m 4 | 5 | if [ ! -e ".env" ]; then 6 | cp .env.example .env 7 | fi 8 | 9 | source ".env" 10 | 11 | anvil --port 8545 & 12 | sleep 1 13 | 14 | export DEPLOYER_PRIVATE_KEY 15 | export ATTESTER_ADDRESS=$(cast wallet address "$SIGNER_PRIVATE_KEY") 16 | export SIGNER_ADDRESS=$(cast wallet address "$SIGNER_PRIVATE_KEY") 17 | export TREASURY_ADDRESS=$(cast wallet address "$SIGNER_PRIVATE_KEY") 18 | 19 | pushd .. 20 | forge script script/DeployTestContracts.sol:DeployTestContracts --rpc-url http://127.0.0.1:8545 --broadcast 2>&1 21 | 22 | fg 23 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeEligibilityCheck.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadge} from "../ScrollBadge.sol"; 6 | 7 | /// @title ScrollBadgeEligibilityCheck 8 | /// @notice This contract adds a standard on-chain eligibility check API. 9 | abstract contract ScrollBadgeEligibilityCheck is ScrollBadge { 10 | /// @notice Check if user is eligible to mint this badge. 11 | /// @param recipient The user's wallet address. 12 | /// @return Whether the user is eligible to mint. 13 | function isEligible(address recipient) external virtual returns (bool) { 14 | return !hasBadge(recipient); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/misc/SBT.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 6 | 7 | contract SBT is ERC721 { 8 | error TransfersDisabled(); 9 | 10 | constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_) { 11 | // empty 12 | } 13 | 14 | function _beforeTokenTransfer(address from, address to, uint256, /*firstTokenId*/ uint256 /*batchSize*/ ) 15 | internal 16 | pure 17 | override 18 | { 19 | if (from != address(0) && to != address(0)) { 20 | revert TransfersDisabled(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/interfaces/IScrollSelfAttestationBadge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {IScrollBadge} from "./IScrollBadge.sol"; 8 | 9 | interface IScrollSelfAttestationBadge is IScrollBadge { 10 | /// @notice Return the unique id of this badge. 11 | function getBadgeId() external view returns (uint256); 12 | 13 | /// @notice Returns an existing attestation by UID. 14 | /// @param uid The UID of the attestation to retrieve. 15 | /// @return The attestation data members. 16 | function getAttestation(bytes32 uid) external view returns (Attestation memory); 17 | } 18 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scroll-canvas-contracts", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "", 6 | "scripts": { 7 | "build": "forge build", 8 | "test": "forge test -vvv", 9 | "fmt": "forge fmt", 10 | "check-badge": "forge script --rpc-url https://rpc.scroll.io script/CheckBadge.s.sol --skip-simulation" 11 | }, 12 | "author": "", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@ethereum-attestation-service/eas-contracts": "^1.3.7", 16 | "@openzeppelin/contracts": "^4.9.3", 17 | "@openzeppelin/contracts-upgradeable": "^4.9.3", 18 | "solmate": "^6.2.0" 19 | }, 20 | "devDependencies": { 21 | "husky": "7" 22 | }, 23 | "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" 24 | } 25 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeNonRevocable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {ScrollBadge} from "../ScrollBadge.sol"; 8 | import {RevocationDisabled} from "../../Errors.sol"; 9 | 10 | /// @title ScrollBadgeNonRevocable 11 | /// @notice This contract disables revocation for this badge. 12 | abstract contract ScrollBadgeNonRevocable is ScrollBadge { 13 | /// @inheritdoc ScrollBadge 14 | function onIssueBadge(Attestation calldata attestation) internal virtual override returns (bool) { 15 | if (!super.onIssueBadge(attestation)) { 16 | return false; 17 | } 18 | 19 | if (attestation.revocable) { 20 | revert RevocationDisabled(); 21 | } 22 | 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/badge/extensions/IScrollBadgeUpgradeable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | /// @title IScrollBadgeUpgradeable 6 | /// @notice This interface defines functions to facilitate badge upgrades. 7 | interface IScrollBadgeUpgradeable { 8 | /// @notice Checks if a badge can be upgraded. 9 | /// @param uid The unique identifier of the badge. 10 | /// @return True if the badge can be upgraded, false otherwise. 11 | function canUpgrade(bytes32 uid) external view returns (bool); 12 | 13 | /// @notice Upgrades a badge. 14 | /// @param uid The unique identifier of the badge. 15 | /// @dev Should revert with CannotUpgrade (from Errors.sol) if the badge cannot be upgraded. 16 | /// @dev Should emit an Upgrade event (custom defined) if the upgrade is successful. 17 | function upgrade(bytes32 uid) external; 18 | } 19 | -------------------------------------------------------------------------------- /examples/.env.example: -------------------------------------------------------------------------------- 1 | RPC_ENDPOINT='http://127.0.0.1:8545' 2 | 3 | EAS_MAIN_CONTRACT_ADDRESS='0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512' 4 | 5 | SCROLL_BADGE_SCHEMA_UID='0x81b69c8f7b364e9f7d8be9c19525df9ec003487dcd39ef647cb1a2f7a241bc08' 6 | SCROLL_BADGE_SCHEMA='address badge, bytes payload' 7 | 8 | SIMPLE_BADGE_CONTRACT_ADDRESS='0x30C98067517f8ee38e748A3aF63429974103Ea6B' 9 | SIMPLE_BADGE_ATTESTER_PROXY_CONTRACT_ADDRESS='0xaEF4103A04090071165F78D45D83A0C0782c2B2a' 10 | 11 | SCROLL_PROFILE_REGISTRY_PROXY_CONTRACT_ADDRESS='0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9' 12 | 13 | # use anvil dev keys 14 | DEPLOYER_PRIVATE_KEY='0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' 15 | SIGNER_PRIVATE_KEY='0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' 16 | CLAIMER_PRIVATE_KEY='0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a' 17 | 18 | EXPRESS_SERVER_PORT='3000' 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "bracketSpacing": true, 6 | "overrides": [ 7 | { 8 | "files": "src/**/*.sol", 9 | "options": { 10 | "printWidth": 120, 11 | "tabWidth": 4, 12 | "useTabs": false, 13 | "singleQuote": false, 14 | "bracketSpacing": false 15 | } 16 | }, 17 | { 18 | "files": "script/**/*.sol", 19 | "options": { 20 | "printWidth": 120, 21 | "tabWidth": 4, 22 | "useTabs": false, 23 | "singleQuote": false, 24 | "bracketSpacing": false 25 | } 26 | }, 27 | { 28 | "files": "test/**/*.sol", 29 | "options": { 30 | "printWidth": 120, 31 | "tabWidth": 4, 32 | "useTabs": false, 33 | "singleQuote": false, 34 | "bracketSpacing": false 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeSingleton.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {ScrollBadge} from "../ScrollBadge.sol"; 8 | import {SingletonBadge} from "../../Errors.sol"; 9 | 10 | /// @title ScrollBadgeSingleton 11 | /// @notice This contract only allows one active badge per wallet. 12 | abstract contract ScrollBadgeSingleton is ScrollBadge { 13 | /// @inheritdoc ScrollBadge 14 | function onIssueBadge(Attestation calldata attestation) internal virtual override returns (bool) { 15 | if (!super.onIssueBadge(attestation)) { 16 | return false; 17 | } 18 | 19 | if (hasBadge(attestation.recipient)) { 20 | revert SingletonBadge(); 21 | } 22 | 23 | return true; 24 | } 25 | 26 | /// @inheritdoc ScrollBadge 27 | function onRevokeBadge(Attestation calldata attestation) internal virtual override returns (bool) { 28 | return super.onRevokeBadge(attestation); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeSelfAttest.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {ScrollBadge} from "../ScrollBadge.sol"; 8 | import {Unauthorized} from "../../Errors.sol"; 9 | 10 | /// @title ScrollBadgeSelfAttest 11 | /// @notice This contract ensures that only the badge recipient can attest. 12 | abstract contract ScrollBadgeSelfAttest is ScrollBadge { 13 | /// @inheritdoc ScrollBadge 14 | function onIssueBadge(Attestation calldata attestation) internal virtual override returns (bool) { 15 | if (!super.onIssueBadge(attestation)) { 16 | return false; 17 | } 18 | 19 | if (attestation.recipient != attestation.attester) { 20 | revert Unauthorized(); 21 | } 22 | 23 | return true; 24 | } 25 | 26 | /// @inheritdoc ScrollBadge 27 | function onRevokeBadge(Attestation calldata attestation) internal virtual override returns (bool) { 28 | return super.onRevokeBadge(attestation); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeDefaultURI.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadge} from "../ScrollBadge.sol"; 6 | 7 | /// @title ScrollBadgeDefaultURI 8 | /// @notice This contract sets a default badge URI. 9 | abstract contract ScrollBadgeDefaultURI is ScrollBadge { 10 | string public defaultBadgeURI; 11 | 12 | constructor(string memory _defaultBadgeURI) { 13 | defaultBadgeURI = _defaultBadgeURI; 14 | } 15 | 16 | /// @inheritdoc ScrollBadge 17 | function badgeTokenURI(bytes32 uid) public view virtual override returns (string memory) { 18 | if (uid == bytes32(0)) { 19 | return defaultBadgeURI; 20 | } 21 | 22 | return getBadgeTokenURI(uid); 23 | } 24 | 25 | /// @notice Returns the token URI corresponding to a certain badge UID. 26 | /// @param {uid} The badge UID. 27 | /// @return The badge token URI (same format as ERC721). 28 | function getBadgeTokenURI(bytes32) internal view virtual returns (string memory) { 29 | return defaultBadgeURI; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Scroll 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeNoExpiry.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | import {NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 7 | 8 | import {ScrollBadge} from "../ScrollBadge.sol"; 9 | import {ExpirationDisabled} from "../../Errors.sol"; 10 | 11 | /// @title ScrollBadgeNoExpiry 12 | /// @notice This contract disables expiration for this badge. 13 | abstract contract ScrollBadgeNoExpiry is ScrollBadge { 14 | /// @inheritdoc ScrollBadge 15 | function onIssueBadge(Attestation calldata attestation) internal virtual override returns (bool) { 16 | if (!super.onIssueBadge(attestation)) { 17 | return false; 18 | } 19 | 20 | if (attestation.expirationTime != NO_EXPIRATION_TIME) { 21 | revert ExpirationDisabled(); 22 | } 23 | 24 | return true; 25 | } 26 | 27 | /// @inheritdoc ScrollBadge 28 | function onRevokeBadge(Attestation calldata attestation) internal virtual override returns (bool) { 29 | return super.onRevokeBadge(attestation); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/ScrollBadgeDefaultURI.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 8 | import {ScrollBadgeDefaultURI} from "../src/badge/extensions/ScrollBadgeDefaultURI.sol"; 9 | 10 | contract TestContract is ScrollBadgeDefaultURI { 11 | constructor(address resolver_) ScrollBadge(resolver_) ScrollBadgeDefaultURI("default") {} 12 | 13 | function getBadgeTokenURI(bytes32 /*uid*/ ) internal pure override returns (string memory) { 14 | return "not-default"; 15 | } 16 | } 17 | 18 | contract ScrollBadgeDefaultURITest is ScrollBadgeTestBase { 19 | TestContract internal badge; 20 | 21 | function setUp() public virtual override { 22 | super.setUp(); 23 | 24 | badge = new TestContract(address(resolver)); 25 | resolver.toggleBadge(address(badge), true); 26 | } 27 | 28 | function testGetBadgeTokenURI() external { 29 | bytes32 uid = _attest(address(badge), "", alice); 30 | 31 | string memory uri = badge.badgeTokenURI(uid); 32 | assertEq(uri, "not-default"); 33 | 34 | uri = badge.badgeTokenURI(bytes32(0)); 35 | assertEq(uri, "default"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/ScrollBadgeSelfAttest.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 8 | import {ScrollBadgeSelfAttest} from "../src/badge/extensions/ScrollBadgeSelfAttest.sol"; 9 | import {Unauthorized} from "../src/Errors.sol"; 10 | 11 | contract TestContract is ScrollBadgeSelfAttest { 12 | constructor(address resolver_) ScrollBadge(resolver_) {} 13 | 14 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 15 | return ""; 16 | } 17 | } 18 | 19 | contract ScrollBadgeSelfAttestTest is ScrollBadgeTestBase { 20 | TestContract internal badge; 21 | 22 | function setUp() public virtual override { 23 | super.setUp(); 24 | 25 | badge = new TestContract(address(resolver)); 26 | resolver.toggleBadge(address(badge), true); 27 | } 28 | 29 | function testAttestToSelf() external { 30 | _attest(address(badge), "", address(this)); 31 | } 32 | 33 | function testAttestToOtherFails(address notSelf) external { 34 | vm.assume(notSelf != address(this)); 35 | vm.expectRevert(Unauthorized.selector); 36 | _attest(address(badge), "", notSelf); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Errors.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | error Unauthorized(); 6 | error CannotUpgrade(bytes32 uid); 7 | 8 | // attestation errors 9 | // note: these don't include the uid since it is not known prior to the attestation. 10 | error BadgeNotAllowed(address badge); 11 | error BadgeNotFound(address badge); 12 | error ExpirationDisabled(); 13 | error MissingPayload(); 14 | error ResolverPaymentsDisabled(); 15 | error RevocationDisabled(); 16 | error SingletonBadge(); 17 | error UnknownSchema(); 18 | 19 | // query errors 20 | error AttestationBadgeMismatch(bytes32 uid); 21 | error AttestationExpired(bytes32 uid); 22 | error AttestationNotFound(bytes32 uid); 23 | error AttestationOwnerMismatch(bytes32 uid); 24 | error AttestationRevoked(bytes32 uid); 25 | error AttestationSchemaMismatch(bytes32 uid); 26 | 27 | // profile errors 28 | error BadgeCountReached(); 29 | error LengthMismatch(); 30 | error TokenNotOwnedByUser(address token, uint256 tokenId); 31 | 32 | // profile registry errors 33 | error CallerIsNotUserProfile(); 34 | error DuplicatedUsername(); 35 | error ExpiredSignature(); 36 | error ImplementationNotContract(); 37 | error InvalidReferrer(); 38 | error InvalidSignature(); 39 | error InvalidUsername(); 40 | error MsgValueMismatchWithMintFee(); 41 | error ProfileAlreadyMinted(); 42 | -------------------------------------------------------------------------------- /src/interfaces/IProfile.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | interface IProfile { 6 | /** 7 | * 8 | * Events * 9 | * 10 | */ 11 | 12 | /// @notice Emitted when a badge is attached. 13 | /// @param uid The id of the badge. 14 | event AttachBadge(bytes32 indexed uid); 15 | 16 | /// @notice Emitted when a badge is detached. 17 | /// @param uid The id of the badge. 18 | event DetachBadge(bytes32 indexed uid); 19 | 20 | /// @notice Emitted when the username is updated. 21 | event ChangeUsername(string oldUsername, string newUsername); 22 | 23 | /// @notice Emitted when the avatar is updated. 24 | event ChangeAvatar(address oldToken, uint256 oldTokenId, address newToken, uint256 newTokenId); 25 | 26 | /// @notice Emitted when the badge order is updated. 27 | event ReorderBadges(uint256 oldOrder, uint256 newOrder); 28 | 29 | /** 30 | * 31 | * Public Mutating Functions * 32 | * 33 | */ 34 | 35 | /// @notice Attach a list of badges to this profile. 36 | /// @param _uids The list of badge uids to attach. 37 | function attach(bytes32[] memory _uids) external; 38 | 39 | /// @notice Auto-attach a badge to this profile. 40 | /// @dev Only callable by the badge resolver contract. 41 | /// @param _uid The badge uid to attach. 42 | function autoAttach(bytes32 _uid) external; 43 | } 44 | -------------------------------------------------------------------------------- /docs/deployments.md: -------------------------------------------------------------------------------- 1 | # Canvas Deployments 2 | 3 | 4 | ## Scroll Mainnet 5 | 6 | ```bash 7 | # EAS constants 8 | SCROLL_MAINNET_EAS_ADDRESS="0xC47300428b6AD2c7D03BB76D05A176058b47E6B0" 9 | SCROLL_MAINNET_EAS_SCHEMA_REGISTRY_ADDRESS="0xD2CDF46556543316e7D34e8eDc4624e2bB95e3B6" 10 | 11 | # Scroll Canvas constants 12 | SCROLL_MAINNET_BADGE_RESOLVER_ADDRESS="0x4560FECd62B14A463bE44D40fE5Cfd595eEc0113" 13 | SCROLL_MAINNET_BADGE_SCHEMA="0xd57de4f41c3d3cc855eadef68f98c0d4edd22d57161d96b7c06d2f4336cc3b49" 14 | SCROLL_MAINNET_PROFILE_REGISTRY_ADDRESS="0xB23AF8707c442f59BDfC368612Bd8DbCca8a7a5a" 15 | 16 | # APIs 17 | SCROLL_MAINNET_RPC_URL="https://rpc.scroll.io" 18 | SCROLL_MAINNET_EAS_GRAPHQL_URL="https://scroll.easscan.org/graphql" 19 | ``` 20 | 21 | 22 | ## Scroll Sepolia 23 | 24 | ```bash 25 | # EAS constants 26 | SCROLL_SEPOLIA_EAS_ADDRESS="0xaEF4103A04090071165F78D45D83A0C0782c2B2a" 27 | SCROLL_SEPOLIA_EAS_SCHEMA_REGISTRY_ADDRESS="0x55D26f9ae0203EF95494AE4C170eD35f4Cf77797" 28 | 29 | # Scroll Canvas constants 30 | SCROLL_SEPOLIA_BADGE_RESOLVER_ADDRESS="0xd2270b3540FD2220Fa1025414e1625af8B0dd8f3" 31 | SCROLL_SEPOLIA_BADGE_SCHEMA="0xa35b5470ebb301aa5d309a8ee6ea258cad680ea112c86e456d5f2254448afc74" 32 | SCROLL_SEPOLIA_PROFILE_REGISTRY_ADDRESS="0x26aa585d5Da74A373E58c4fA723E1E1f6FD6474f" 33 | 34 | # APIs 35 | SCROLL_SEPOLIA_RPC_URL="https://sepolia-rpc.scroll.io" 36 | SCROLL_SEPOLIA_EAS_GRAPHQL_URL="https://scroll-sepolia.easscan.org/graphql" 37 | ``` 38 | -------------------------------------------------------------------------------- /src/badge/examples/ScrollBadgeSimple.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {ScrollBadge} from "../ScrollBadge.sol"; 8 | import {ScrollBadgeAccessControl} from "../extensions/ScrollBadgeAccessControl.sol"; 9 | import {ScrollBadgeDefaultURI} from "../extensions/ScrollBadgeDefaultURI.sol"; 10 | import {ScrollBadgeSingleton} from "../extensions/ScrollBadgeSingleton.sol"; 11 | 12 | /// @title ScrollBadgeSimple 13 | /// @notice A simple badge that has the same static metadata for each token. 14 | contract ScrollBadgeSimple is ScrollBadgeAccessControl, ScrollBadgeDefaultURI, ScrollBadgeSingleton { 15 | constructor(address resolver_, string memory tokenUri_) ScrollBadge(resolver_) ScrollBadgeDefaultURI(tokenUri_) { 16 | // empty 17 | } 18 | 19 | /// @inheritdoc ScrollBadge 20 | function onIssueBadge(Attestation calldata attestation) 21 | internal 22 | override (ScrollBadge, ScrollBadgeAccessControl, ScrollBadgeSingleton) 23 | returns (bool) 24 | { 25 | return super.onIssueBadge(attestation); 26 | } 27 | 28 | /// @inheritdoc ScrollBadge 29 | function onRevokeBadge(Attestation calldata attestation) 30 | internal 31 | override (ScrollBadge, ScrollBadgeAccessControl, ScrollBadgeSingleton) 32 | returns (bool) 33 | { 34 | return super.onRevokeBadge(attestation); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/src/decode-error.js: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | 3 | import 'dotenv/config'; 4 | 5 | const abi = [ 6 | 'error Unauthorized()', 7 | 8 | 'error BadgeNotAllowed(address badge)', 9 | 'error BadgeNotFound(address badge)', 10 | 'error ExpirationDisabled()', 11 | 'error MissingPayload()', 12 | 'error ResolverPaymentsDisabled()', 13 | 'error RevocationDisabled()', 14 | 'error SingletonBadge()', 15 | 'error UnknownSchema()', 16 | 17 | 'error AttestationBadgeMismatch(bytes32 uid)', 18 | 'error AttestationExpired(bytes32 uid)', 19 | 'error AttestationNotFound(bytes32 uid)', 20 | 'error AttestationOwnerMismatch(bytes32 uid)', 21 | 'error AttestationRevoked(bytes32 uid)', 22 | 'error AttestationSchemaMismatch(bytes32 uid)', 23 | 24 | 'error BadgeCountReached()', 25 | 'error LengthMismatch()', 26 | 'error TokenNotOwnedByUser(address token, uint256 tokenId)', 27 | 28 | 'error CallerIsNotUserProfile()', 29 | 'error DuplicatedUsername()', 30 | 'error ExpiredSignature()', 31 | 'error ImplementationNotContract()', 32 | 'error InvalidReferrer()', 33 | 'error InvalidSignature()', 34 | 'error InvalidUsername()', 35 | 'error MsgValueMismatchWithMintFee()', 36 | 'error ProfileAlreadyMinted()', 37 | ]; 38 | 39 | async function main() { 40 | const errData = '0x8baa579f'; 41 | const contract = new ethers.Interface(abi); 42 | const decodedError = contract.parseError(errData); 43 | console.log('error:', decodedError.name); 44 | } 45 | 46 | main(); 47 | -------------------------------------------------------------------------------- /test/ScrollBadgeSingleton.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 8 | import {ScrollBadgeSingleton} from "../src/badge/extensions/ScrollBadgeSingleton.sol"; 9 | import {SingletonBadge} from "../src/Errors.sol"; 10 | 11 | contract TestContract is ScrollBadgeSingleton { 12 | constructor(address resolver_) ScrollBadge(resolver_) {} 13 | 14 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 15 | return ""; 16 | } 17 | } 18 | 19 | contract ScrollBadgeSingletonTest is ScrollBadgeTestBase { 20 | TestContract internal badge; 21 | 22 | function setUp() public virtual override { 23 | super.setUp(); 24 | 25 | badge = new TestContract(address(resolver)); 26 | resolver.toggleBadge(address(badge), true); 27 | } 28 | 29 | function testAttestOnce(address recipient) external { 30 | _attest(address(badge), "", recipient); 31 | } 32 | 33 | function testAttestRevokeAttest(address recipient) external { 34 | bytes32 uid = _attest(address(badge), "", recipient); 35 | _revoke(uid); 36 | _attest(address(badge), "", recipient); 37 | } 38 | 39 | function testAttestTwiceFails(address recipient) external { 40 | _attest(address(badge), "", recipient); 41 | 42 | vm.expectRevert(SingletonBadge.selector); 43 | _attest(address(badge), "", recipient); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/contracts.yml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | 3 | name: test 4 | 5 | jobs: 6 | check: 7 | name: Foundry project 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | with: 12 | submodules: recursive 13 | 14 | - name: Install Foundry 15 | uses: foundry-rs/foundry-toolchain@v1 16 | 17 | - name: Install Node.js 18 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: '18' 21 | 22 | - name: Get yarn cache directory path 23 | id: yarn-cache-dir-path 24 | run: echo "::set-output name=dir::$(yarn cache dir)" 25 | 26 | - name: Cache yarn dependencies 27 | uses: actions/cache@v2 28 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 29 | with: 30 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 31 | key: ${{ runner.os }}-yarn-${{ hashFiles('contracts/yarn.lock') }} 32 | restore-keys: | 33 | ${{ runner.os }}-yarn- 34 | 35 | - name: Cache node_modules 36 | id: npm_cache 37 | uses: actions/cache@v2 38 | with: 39 | path: node_modules 40 | key: node_modules-${{ hashFiles('contracts/yarn.lock') }} 41 | 42 | - name: yarn install 43 | # if: steps.npm_cache.outputs.cache-hit != 'true' 44 | run: yarn install 45 | 46 | - name: Compile with foundry 47 | run: forge build 48 | 49 | - name: Run foundry tests 50 | run: forge test -vvv 51 | 52 | -------------------------------------------------------------------------------- /src/resolver/ScrollBadgeResolverWhitelist.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; 6 | 7 | abstract contract ScrollBadgeResolverWhitelist is OwnableUpgradeable { 8 | /** 9 | * 10 | * Variables * 11 | * 12 | */ 13 | 14 | // If false, all badges are allowed. 15 | bool public whitelistEnabled; 16 | 17 | // Authorized badge contracts. 18 | mapping(address => bool) public whitelist; 19 | 20 | // Storage slots reserved for future upgrades. 21 | uint256[48] private __gap; 22 | 23 | /** 24 | * 25 | * Constructor * 26 | * 27 | */ 28 | constructor() { 29 | _disableInitializers(); 30 | } 31 | 32 | function __Whitelist_init() internal onlyInitializing { 33 | __Ownable_init(); 34 | whitelistEnabled = true; 35 | } 36 | 37 | /** 38 | * 39 | * Restricted Functions * 40 | * 41 | */ 42 | 43 | /// @notice Enables or disables a given badge contract. 44 | /// @param badge The badge address. 45 | /// @param enable True if enable, false if disable. 46 | function toggleBadge(address badge, bool enable) external onlyOwner { 47 | whitelist[badge] = enable; 48 | } 49 | 50 | /// @notice Enables or disables the badge whitelist. 51 | /// @param enable True if enable, false if disable. 52 | function toggleWhitelist(bool enable) external onlyOwner { 53 | whitelistEnabled = enable; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /docs/official-badges/ethereum-year-badge.md: -------------------------------------------------------------------------------- 1 | # Canvas Ethereum Year Badge 2 | 3 | In the examples on this page, we use the configurations from [deployments.md](./deployments.md), as well as the following values: 4 | 5 | ```bash 6 | SCROLL_MAINNET_ETHEREUM_YEAR_BADGE_ADDRESS=0x3dacAd961e5e2de850F5E027c70b56b5Afa5DfeD 7 | SCROLL_MAINNET_ETHEREUM_YEAR_ATTESTER_PROXY_ADDRESS=0x39fb5E85C7713657c2D9E869E974FF1e0B06F20C 8 | 9 | SCROLL_SEPOLIA_ETHEREUM_YEAR_BADGE_ADDRESS=0xB59B6466B21a089c93B14030AF88b164905a58fd 10 | SCROLL_SEPOLIA_ETHEREUM_YEAR_ATTESTER_PROXY_ADDRESS=0xdAe8D9a30681899C305534849e138579aF0BF88e 11 | ``` 12 | 13 | This badge uses backend-authorized delegated attestations. For details, refer to [badges.md](./badges.md). For an example of producing delegated attestations, refer to [attest-server.js](../examples/src/attest-server.js). 14 | 15 | ### How to encode the badge payload? 16 | 17 | Each badge is an attestation, whose `data` field contains the abi-encoded badge payload, using the following schema: 18 | 19 | ``` 20 | address badge, bytes payload 21 | ``` 22 | 23 | Where `payload` uses the following schema: 24 | 25 | ``` 26 | uint256 year 27 | ``` 28 | 29 | Example: 30 | 31 | ```bash 32 | > PAYLOAD=$(cast abi-encode "foo(uint256)" "2024") 33 | > ATTESTATION_PAYLOAD=$(cast abi-encode "foo(address,bytes)" "0xB59B6466B21a089c93B14030AF88b164905a58fd" "$PAYLOAD") 34 | > echo "$ATTESTATION_PAYLOAD" 35 | 0x000000000000000000000000b59b6466b21a089c93b14030af88b164905a58fd0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000007e8 36 | ``` 37 | -------------------------------------------------------------------------------- /test/EthereumYearBadge.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 8 | import {AttestationRequest, AttestationRequestData} from "@eas/contracts/IEAS.sol"; 9 | 10 | import {EthereumYearBadge} from "../src/badge/examples/EthereumYearBadge.sol"; 11 | 12 | contract EthereumYearBadgeTest is ScrollBadgeTestBase { 13 | EthereumYearBadge internal badge; 14 | 15 | string baseTokenURI = "http://scroll-canvas.io/"; 16 | 17 | function setUp() public virtual override { 18 | super.setUp(); 19 | 20 | badge = new EthereumYearBadge(address(resolver), baseTokenURI); 21 | resolver.toggleBadge(address(badge), true); 22 | badge.toggleAttester(address(this), true); 23 | } 24 | 25 | function testAttestOnce(address recipient) external { 26 | bytes memory payload = abi.encode(2024); 27 | bytes memory attestationData = abi.encode(badge, payload); 28 | 29 | AttestationRequestData memory _attData = AttestationRequestData({ 30 | recipient: recipient, 31 | expirationTime: NO_EXPIRATION_TIME, 32 | revocable: false, 33 | refUID: EMPTY_UID, 34 | data: attestationData, 35 | value: 0 36 | }); 37 | 38 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 39 | bytes32 uid = eas.attest(_req); 40 | 41 | string memory uri = badge.badgeTokenURI(uid); 42 | assertEq(uri, "http://scroll-canvas.io/2024.json"); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/src/attest-simple.js: -------------------------------------------------------------------------------- 1 | import { EAS, getUIDsFromMultiAttestTx } from '@ethereum-attestation-service/eas-sdk'; 2 | import { EIP712Proxy } from '@ethereum-attestation-service/eas-sdk/dist/eip712-proxy.js'; 3 | import { ethers } from 'ethers'; 4 | 5 | import { createBadge } from './lib.js'; 6 | 7 | import 'dotenv/config'; 8 | 9 | const abi = [ 10 | 'error SingletonBadge()', 11 | 'error Unauthorized()' 12 | ] 13 | 14 | async function main() { 15 | const provider = new ethers.JsonRpcProvider(process.env.RPC_ENDPOINT); 16 | 17 | const eas = new EAS(process.env.EAS_MAIN_CONTRACT_ADDRESS); 18 | const attesterProxy = new EIP712Proxy(process.env.SIMPLE_BADGE_ATTESTER_PROXY_CONTRACT_ADDRESS); 19 | 20 | eas.connect(provider); 21 | attesterProxy.connect(provider); 22 | 23 | const contract = new ethers.Interface(abi); 24 | 25 | const signer = (new ethers.Wallet(process.env.SIGNER_PRIVATE_KEY)).connect(provider); 26 | const claimer = (new ethers.Wallet(process.env.CLAIMER_PRIVATE_KEY)).connect(provider); 27 | 28 | const badge = await createBadge({ 29 | badge: process.env.SIMPLE_BADGE_CONTRACT_ADDRESS, 30 | recipient: claimer.address, 31 | payload: '0x', 32 | proxy: attesterProxy, 33 | signer, 34 | }); 35 | 36 | try { 37 | const res = await attesterProxy.connect(claimer).attestByDelegationProxy(badge); 38 | 39 | const uids = await getUIDsFromMultiAttestTx(res.tx); 40 | console.log(uids); 41 | // const attestation = await eas.getAttestation(uid); 42 | } catch (err) { 43 | console.log(); 44 | const decodedError = contract.parseError(err.data) 45 | console.log('error:', decodedError.name) 46 | } 47 | } 48 | 49 | main(); 50 | -------------------------------------------------------------------------------- /src/badge/examples/ScrollBadgeWhale.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {ScrollBadge} from "../ScrollBadge.sol"; 8 | import {ScrollBadgePermissionless} from "./ScrollBadgePermissionless.sol"; 9 | import {ScrollBadgeEligibilityCheck} from "../extensions/ScrollBadgeEligibilityCheck.sol"; 10 | import {Unauthorized} from "../../Errors.sol"; 11 | 12 | /// @title ScrollBadgeWhale 13 | /// @notice A badge that shows that the user had 1000 ETH or more at the time of minting. 14 | contract ScrollBadgeWhale is ScrollBadgePermissionless { 15 | constructor(address resolver_, string memory _defaultBadgeURI) 16 | ScrollBadgePermissionless(resolver_, _defaultBadgeURI) 17 | { 18 | // empty 19 | } 20 | 21 | /// @inheritdoc ScrollBadge 22 | function onIssueBadge(Attestation calldata attestation) internal override returns (bool) { 23 | if (!super.onIssueBadge(attestation)) { 24 | return false; 25 | } 26 | 27 | if (attestation.recipient.balance < 1000 ether) { 28 | revert Unauthorized(); 29 | } 30 | 31 | return true; 32 | } 33 | 34 | /// @inheritdoc ScrollBadge 35 | function onRevokeBadge(Attestation calldata attestation) internal override returns (bool) { 36 | if (!super.onRevokeBadge(attestation)) { 37 | return false; 38 | } 39 | 40 | return true; 41 | } 42 | 43 | /// @inheritdoc ScrollBadgeEligibilityCheck 44 | function isEligible(address recipient) external view override returns (bool) { 45 | return !hasBadge(recipient) && recipient.balance >= 1000 ether; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/badge/examples/ScrollBadgePermissionless.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {ScrollBadge} from "../ScrollBadge.sol"; 8 | import {ScrollBadgeDefaultURI} from "../extensions/ScrollBadgeDefaultURI.sol"; 9 | import {ScrollBadgeEligibilityCheck} from "../extensions/ScrollBadgeEligibilityCheck.sol"; 10 | import {ScrollBadgeSelfAttest} from "../extensions/ScrollBadgeSelfAttest.sol"; 11 | import {ScrollBadgeSingleton} from "../extensions/ScrollBadgeSingleton.sol"; 12 | 13 | /// @title ScrollBadgePermissionless 14 | /// @notice A simple badge that anyone can mint in a permissionless manner. 15 | contract ScrollBadgePermissionless is 16 | ScrollBadgeDefaultURI, 17 | ScrollBadgeEligibilityCheck, 18 | ScrollBadgeSelfAttest, 19 | ScrollBadgeSingleton 20 | { 21 | constructor(address resolver_, string memory _defaultBadgeURI) 22 | ScrollBadge(resolver_) 23 | ScrollBadgeDefaultURI(_defaultBadgeURI) 24 | { 25 | // empty 26 | } 27 | 28 | /// @inheritdoc ScrollBadge 29 | function onIssueBadge(Attestation calldata attestation) 30 | internal 31 | virtual 32 | override (ScrollBadge, ScrollBadgeSelfAttest, ScrollBadgeSingleton) 33 | returns (bool) 34 | { 35 | return super.onIssueBadge(attestation); 36 | } 37 | 38 | /// @inheritdoc ScrollBadge 39 | function onRevokeBadge(Attestation calldata attestation) 40 | internal 41 | virtual 42 | override (ScrollBadge, ScrollBadgeSelfAttest, ScrollBadgeSingleton) 43 | returns (bool) 44 | { 45 | return super.onRevokeBadge(attestation); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/interfaces/IScrollBadge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | interface IScrollBadge { 8 | event IssueBadge(bytes32 indexed uid); 9 | event RevokeBadge(bytes32 indexed uid); 10 | 11 | /// @notice A resolver callback invoked in the `issueBadge` function in the parent contract. 12 | /// @param attestation The new attestation. 13 | /// @return Whether the attestation is valid. 14 | function issueBadge(Attestation calldata attestation) external returns (bool); 15 | 16 | /// @notice A resolver callback invoked in the `revokeBadge` function in the parent contract. 17 | /// @param attestation The new attestation. 18 | /// @return Whether the attestation can be revoked. 19 | function revokeBadge(Attestation calldata attestation) external returns (bool); 20 | 21 | /// @notice Validate and return a Scroll badge attestation. 22 | /// @param uid The attestation UID. 23 | /// @return The attestation. 24 | function getAndValidateBadge(bytes32 uid) external view returns (Attestation memory); 25 | 26 | /// @notice Returns the token URI corresponding to a certain badge UID, or the default 27 | /// badge token URI if the pass UID is 0x0. 28 | /// @param uid The badge UID, or 0x0. 29 | /// @return The badge token URI (same format as ERC721). 30 | function badgeTokenURI(bytes32 uid) external view returns (string memory); 31 | 32 | /// @notice Returns true if the user has one or more of this badge. 33 | /// @param user The user's wallet address. 34 | /// @return True if the user has one or more of this badge. 35 | function hasBadge(address user) external view returns (bool); 36 | } 37 | -------------------------------------------------------------------------------- /src/interfaces/IScrollBadgeResolver.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | interface IScrollBadgeResolver { 8 | /** 9 | * 10 | * Events * 11 | * 12 | */ 13 | 14 | /// @dev Emitted when a new badge is issued. 15 | /// @param uid The UID of the new badge attestation. 16 | event IssueBadge(bytes32 indexed uid); 17 | 18 | /// @dev Emitted when a badge is revoked. 19 | /// @param uid The UID of the revoked badge attestation. 20 | event RevokeBadge(bytes32 indexed uid); 21 | 22 | /// @dev Emitted when the auto-attach status of a badge is updated. 23 | /// @param badge The address of the badge contract. 24 | /// @param enable Auto-attach was enabled if true, disabled if false. 25 | event UpdateAutoAttachWhitelist(address indexed badge, bool indexed enable); 26 | 27 | /** 28 | * 29 | * Public View Functions * 30 | * 31 | */ 32 | 33 | /// @notice Return the Scroll badge attestation schema. 34 | /// @return The GUID of the Scroll badge attestation schema. 35 | function schema() external view returns (bytes32); 36 | 37 | /// @notice The profile registry contract. 38 | /// @return The address of the profile registry. 39 | function registry() external view returns (address); 40 | 41 | /// @notice The global EAS contract. 42 | /// @return The address of the global EAS contract. 43 | function eas() external view returns (address); 44 | 45 | /// @notice Validate and return a Scroll badge attestation. 46 | /// @param uid The attestation UID. 47 | /// @return The attestation. 48 | function getAndValidateBadge(bytes32 uid) external view returns (Attestation memory); 49 | } 50 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeCustomPayload.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {ScrollBadge} from "../ScrollBadge.sol"; 8 | import {decodeBadgeData} from "../../Common.sol"; 9 | import {MissingPayload} from "../../Errors.sol"; 10 | 11 | /// @title ScrollBadgeCustomPayload 12 | /// @notice This contract adds custom payload to ScrollBadge. 13 | abstract contract ScrollBadgeCustomPayload is ScrollBadge { 14 | /// @inheritdoc ScrollBadge 15 | function onIssueBadge(Attestation calldata attestation) internal virtual override returns (bool) { 16 | if (!super.onIssueBadge(attestation)) { 17 | return false; 18 | } 19 | 20 | bytes memory payload = getPayload(attestation); 21 | 22 | if (payload.length == 0) { 23 | revert MissingPayload(); 24 | } 25 | 26 | return true; 27 | } 28 | 29 | /// @inheritdoc ScrollBadge 30 | function onRevokeBadge(Attestation calldata attestation) internal virtual override returns (bool) { 31 | return super.onRevokeBadge(attestation); 32 | } 33 | 34 | /// @notice Return the badge payload. 35 | /// @param badge The Scroll badge attestation. 36 | /// @return The abi encoded badge payload. 37 | function getPayload(Attestation memory badge) public pure returns (bytes memory) { 38 | (, bytes memory payload) = decodeBadgeData(badge.data); 39 | return payload; 40 | } 41 | 42 | /// @notice Return the badge custom payload schema. 43 | /// @return The custom abi encoding schema used for the payload. 44 | /// @dev This schema serves as a decoding hint for clients. 45 | function getSchema() public virtual returns (string memory); 46 | } 47 | -------------------------------------------------------------------------------- /examples/src/referral.js: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | 3 | import 'dotenv/config'; 4 | 5 | const SCROLL_REFERRAL_DOMAIN = { 6 | name: 'ProfileRegistry', 7 | version: '1', 8 | chainId: 1, // set correct chain id 9 | verifyingContract: process.env.SCROLL_PROFILE_REGISTRY_PROXY_CONTRACT_ADDRESS, 10 | }; 11 | 12 | const SCROLL_REFERRAL_TYPES = { 13 | Referral: [ 14 | { name: 'referrer', type: 'address' }, 15 | { name: 'owner', type: 'address' }, 16 | { name: 'deadline', type: 'uint256' }, 17 | ], 18 | }; 19 | 20 | async function signTypedData() { 21 | const provider = new ethers.JsonRpcProvider(process.env.RPC_ENDPOINT); 22 | const signer = (new ethers.Wallet(process.env.SIGNER_PRIVATE_KEY)).connect(provider); 23 | const referrer = (new ethers.Wallet(process.env.DEPLOYER_PRIVATE_KEY)).address; 24 | const owner = (new ethers.Wallet(process.env.CLAIMER_PRIVATE_KEY)).address; 25 | 26 | // set correct chain ID 27 | const chainId = (await provider.getNetwork()).chainId; 28 | SCROLL_REFERRAL_DOMAIN.chainId = chainId; 29 | 30 | // set deadline 31 | const currentTime = Math.floor(new Date().getTime() / 1000); 32 | const deadline = currentTime + 3600; 33 | 34 | // construct and sign message 35 | const message = { 36 | referrer, 37 | owner, 38 | deadline, 39 | }; 40 | 41 | // note: replay protection is built into the contract, since one wallet can only mint one profile. 42 | 43 | const signature = await signer.signTypedData(SCROLL_REFERRAL_DOMAIN, SCROLL_REFERRAL_TYPES, message); 44 | console.log('Signature:', signature); 45 | 46 | const coder = ethers.AbiCoder.defaultAbiCoder(); 47 | const referral = coder.encode(['address', 'uint256', 'bytes'], [referrer, deadline, signature]); 48 | console.log('Referral:', referral); 49 | } 50 | 51 | signTypedData(); 52 | -------------------------------------------------------------------------------- /test/ScrollBadgeCustomPayload.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {Attestation} from "@eas/contracts/IEAS.sol"; 8 | 9 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 10 | import {ScrollBadgeCustomPayload} from "../src/badge/extensions/ScrollBadgeCustomPayload.sol"; 11 | import {MissingPayload} from "../src/Errors.sol"; 12 | 13 | contract TestContract is ScrollBadgeCustomPayload { 14 | constructor(address resolver_) ScrollBadge(resolver_) {} 15 | 16 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 17 | return ""; 18 | } 19 | 20 | function getSchema() public pure override returns (string memory) { 21 | return "string abc"; 22 | } 23 | } 24 | 25 | contract ScrollBadgeCustomPayloadTest is ScrollBadgeTestBase { 26 | TestContract internal badge; 27 | 28 | function setUp() public virtual override { 29 | super.setUp(); 30 | 31 | badge = new TestContract(address(resolver)); 32 | resolver.toggleBadge(address(badge), true); 33 | } 34 | 35 | function testAttestWithPayload(string memory message) external { 36 | bytes memory payload = abi.encode(message); 37 | bytes32 uid = _attest(address(badge), payload, alice); 38 | 39 | Attestation memory attestation = badge.getAndValidateBadge(uid); 40 | bytes memory payload2 = badge.getPayload(attestation); 41 | string memory message2 = abi.decode(payload2, (string)); 42 | 43 | assertEq(message2, message); 44 | } 45 | 46 | function testAttestWithEmptyPayloadFails() external { 47 | vm.expectRevert(MissingPayload.selector); 48 | _attest(address(badge), "", alice); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/ScrollBadgeNoExpiry.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {Attestation, AttestationRequest, AttestationRequestData} from "@eas/contracts/IEAS.sol"; 8 | import {EMPTY_UID} from "@eas/contracts/Common.sol"; 9 | 10 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 11 | import {ScrollBadgeNoExpiry} from "../src/badge/extensions/ScrollBadgeNoExpiry.sol"; 12 | import {ExpirationDisabled} from "../src/Errors.sol"; 13 | 14 | contract TestContract is ScrollBadgeNoExpiry { 15 | constructor(address resolver_) ScrollBadge(resolver_) {} 16 | 17 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 18 | return ""; 19 | } 20 | } 21 | 22 | contract ScrollBadgeNoExpiryTest is ScrollBadgeTestBase { 23 | TestContract internal badge; 24 | 25 | function setUp() public virtual override { 26 | super.setUp(); 27 | 28 | badge = new TestContract(address(resolver)); 29 | resolver.toggleBadge(address(badge), true); 30 | } 31 | 32 | function testAttestWithoutExpiration() external { 33 | _attest(address(badge), "", alice); 34 | } 35 | 36 | function testAttestWithExpiryFails(uint64 expirationTime) external { 37 | vm.assume(expirationTime > uint64(block.timestamp)); 38 | 39 | AttestationRequestData memory _attData = AttestationRequestData({ 40 | recipient: alice, 41 | expirationTime: expirationTime, 42 | revocable: true, 43 | refUID: EMPTY_UID, 44 | data: abi.encode(badge, ""), 45 | value: 0 46 | }); 47 | 48 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 49 | 50 | vm.expectRevert(ExpirationDisabled.selector); 51 | eas.attest(_req); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/src/lib.js: -------------------------------------------------------------------------------- 1 | import { SchemaEncoder, ZERO_BYTES32, NO_EXPIRATION } from '@ethereum-attestation-service/eas-sdk'; 2 | 3 | export function normalizeAddress(address) { 4 | return address.toLowerCase(); 5 | } 6 | 7 | export async function createAttestation({ schema, recipient, data, deadline, proxy, signer }) { 8 | const attestation = { 9 | // attestation data 10 | schema, 11 | recipient, 12 | data, 13 | 14 | // unused fields 15 | revocable: true, 16 | refUID: ZERO_BYTES32, 17 | value: 0n, 18 | expirationTime: NO_EXPIRATION, 19 | 20 | // signature details 21 | deadline, 22 | attester: signer.address, 23 | }; 24 | 25 | // sign 26 | const delegatedProxy = await proxy.connect(signer).getDelegated(); 27 | const signature = await delegatedProxy.signDelegatedProxyAttestation(attestation, signer); 28 | 29 | const req = { 30 | schema: attestation.schema, 31 | data: attestation, 32 | attester: attestation.attester, 33 | signature: signature.signature, 34 | deadline: attestation.deadline, 35 | } 36 | 37 | // note: to use multiAttestByDelegationProxy, change to 38 | // data: [attestation], 39 | // signatures: [signature.signature], 40 | 41 | return req; 42 | } 43 | 44 | export async function createBadge({ badge, recipient, payload, proxy, signer }) { 45 | const encoder = new SchemaEncoder(process.env.SCROLL_BADGE_SCHEMA); 46 | const data = encoder.encodeData([ 47 | { name: "badge", value: badge, type: "address" }, 48 | { name: "payload", value: payload, type: "bytes" }, 49 | ]); 50 | 51 | const currentTime = Math.floor(new Date().getTime() / 1000); 52 | const deadline = currentTime + 3600; 53 | 54 | const attestation = await createAttestation({ 55 | schema: process.env.SCROLL_BADGE_SCHEMA_UID, 56 | recipient, 57 | data, 58 | deadline, 59 | proxy, 60 | signer, 61 | }); 62 | 63 | return attestation; 64 | } 65 | -------------------------------------------------------------------------------- /test/ScrollBadgeSBT.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {SBT} from "../src/misc/SBT.sol"; 8 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 9 | import {ScrollBadgeSBT} from "../src/badge/extensions/ScrollBadgeSBT.sol"; 10 | 11 | contract TestContract is ScrollBadgeSBT { 12 | constructor(address resolver_) ScrollBadge(resolver_) ScrollBadgeSBT("name", "symbol") {} 13 | 14 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 15 | return "uri"; 16 | } 17 | } 18 | 19 | contract ScrollBadgeSBTTest is ScrollBadgeTestBase { 20 | TestContract internal badge; 21 | 22 | function setUp() public virtual override { 23 | super.setUp(); 24 | 25 | badge = new TestContract(address(resolver)); 26 | resolver.toggleBadge(address(badge), true); 27 | } 28 | 29 | function testAttest() external { 30 | // mint single token 31 | bytes32 uid = _attest(address(badge), "", alice); 32 | 33 | bool isValid = eas.isAttestationValid(uid); 34 | assertTrue(isValid); 35 | 36 | uint256 balance = badge.balanceOf(alice); 37 | assertEq(balance, 1); 38 | 39 | uint256 tokenId = uint256(uid); 40 | address owner = badge.ownerOf(uint256(uid)); 41 | assertEq(owner, alice); 42 | 43 | string memory tokenUri = badge.tokenURI(tokenId); 44 | assertEq(tokenUri, "uri"); 45 | 46 | // cannot transfer token 47 | vm.prank(alice); 48 | vm.expectRevert(SBT.TransfersDisabled.selector); 49 | badge.transferFrom(alice, bob, tokenId); 50 | 51 | // revoke 52 | _revoke(uid); 53 | 54 | balance = badge.balanceOf(alice); 55 | assertEq(balance, 0); 56 | 57 | vm.expectRevert("ERC721: invalid token ID"); 58 | badge.ownerOf(uint256(uid)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/ScrollBadgeAccessControl.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 8 | import {ScrollBadgeAccessControl} from "../src/badge/extensions/ScrollBadgeAccessControl.sol"; 9 | import {Unauthorized} from "../src/Errors.sol"; 10 | 11 | contract TestContract is ScrollBadgeAccessControl { 12 | constructor(address resolver_) ScrollBadge(resolver_) {} 13 | 14 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 15 | return ""; 16 | } 17 | } 18 | 19 | contract ScrollBadgeAccessControlTest is ScrollBadgeTestBase { 20 | TestContract internal badge; 21 | 22 | function setUp() public virtual override { 23 | super.setUp(); 24 | 25 | badge = new TestContract(address(resolver)); 26 | resolver.toggleBadge(address(badge), true); 27 | } 28 | 29 | function testAuthorized() external { 30 | badge.toggleAttester(address(this), true); 31 | bytes32 uid = _attest(address(badge), "", alice); 32 | _revoke(uid); 33 | } 34 | 35 | function testUnauthorizedAttest() external { 36 | vm.expectRevert(Unauthorized.selector); 37 | _attest(address(badge), "", alice); 38 | } 39 | 40 | function testUnauthorizedRevoke() external { 41 | badge.toggleAttester(address(this), true); 42 | bytes32 uid = _attest(address(badge), "", alice); 43 | 44 | badge.toggleAttester(address(this), false); 45 | vm.expectRevert(Unauthorized.selector); 46 | _revoke(uid); 47 | } 48 | 49 | function testToggleAttesterOnlyOwner(address notOwner, address anyAttester, bool enable) external { 50 | vm.assume(notOwner != address(this)); 51 | vm.prank(notOwner); 52 | vm.expectRevert("Ownable: caller is not the owner"); 53 | badge.toggleAttester(anyAttester, enable); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeAccessControl.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 8 | 9 | import {ScrollBadge} from "../ScrollBadge.sol"; 10 | import {Unauthorized} from "../../Errors.sol"; 11 | 12 | /// @title ScrollBadgeAccessControl 13 | /// @notice This contract adds access control to ScrollBadge. 14 | /// @dev In EAS, only the original attester can revoke an attestation. If the original 15 | // attester was removed and a new was added in this contract, it will not be able 16 | // to revoke previous attestations. 17 | abstract contract ScrollBadgeAccessControl is Ownable, ScrollBadge { 18 | // Authorized badge issuer and revoker accounts. 19 | mapping(address => bool) public isAttester; 20 | 21 | /// @notice Enables or disables a given attester. 22 | /// @param attester The attester address. 23 | /// @param enable True if enable, false if disable. 24 | function toggleAttester(address attester, bool enable) external onlyOwner { 25 | isAttester[attester] = enable; 26 | } 27 | 28 | /// @inheritdoc ScrollBadge 29 | function onIssueBadge(Attestation calldata attestation) internal virtual override returns (bool) { 30 | if (!super.onIssueBadge(attestation)) { 31 | return false; 32 | } 33 | 34 | // only allow authorized issuers 35 | if (!isAttester[attestation.attester]) { 36 | revert Unauthorized(); 37 | } 38 | 39 | return true; 40 | } 41 | 42 | /// @inheritdoc ScrollBadge 43 | function onRevokeBadge(Attestation calldata attestation) internal virtual override returns (bool) { 44 | if (!super.onRevokeBadge(attestation)) { 45 | return false; 46 | } 47 | 48 | // only allow authorized revokers 49 | if (!isAttester[attestation.attester]) { 50 | revert Unauthorized(); 51 | } 52 | 53 | return true; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/ScrollBadgeNonRevocable.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 8 | import {Attestation, AttestationRequest, AttestationRequestData} from "@eas/contracts/IEAS.sol"; 9 | 10 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 11 | import {ScrollBadgeNonRevocable} from "../src/badge/extensions/ScrollBadgeNonRevocable.sol"; 12 | import {RevocationDisabled} from "../src/Errors.sol"; 13 | 14 | contract TestContract is ScrollBadgeNonRevocable { 15 | constructor(address resolver_) ScrollBadge(resolver_) {} 16 | 17 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 18 | return ""; 19 | } 20 | } 21 | 22 | contract ScrollBadgeNonRevocableTest is ScrollBadgeTestBase { 23 | TestContract internal badge; 24 | 25 | function setUp() public virtual override { 26 | super.setUp(); 27 | 28 | badge = new TestContract(address(resolver)); 29 | resolver.toggleBadge(address(badge), true); 30 | } 31 | 32 | function testAttestNonRevocable() external { 33 | AttestationRequestData memory _attData = AttestationRequestData({ 34 | recipient: alice, 35 | expirationTime: NO_EXPIRATION_TIME, 36 | revocable: false, 37 | refUID: EMPTY_UID, 38 | data: abi.encode(badge, ""), 39 | value: 0 40 | }); 41 | 42 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 43 | 44 | eas.attest(_req); 45 | } 46 | 47 | function testAttestRevocableFails() external { 48 | AttestationRequestData memory _attData = AttestationRequestData({ 49 | recipient: alice, 50 | expirationTime: NO_EXPIRATION_TIME, 51 | revocable: true, 52 | refUID: EMPTY_UID, 53 | data: abi.encode(badge, ""), 54 | value: 0 55 | }); 56 | 57 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 58 | 59 | vm.expectRevert(RevocationDisabled.selector); 60 | eas.attest(_req); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/badge/extensions/ScrollBadgeSBT.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 8 | 9 | import {SBT} from "../../misc/SBT.sol"; 10 | import {ScrollBadge} from "../ScrollBadge.sol"; 11 | import {ScrollBadgeNoExpiry} from "./ScrollBadgeNoExpiry.sol"; 12 | 13 | /// @title ScrollBadgeSBT 14 | /// @notice This contract attaches an SBT token to each badge. 15 | abstract contract ScrollBadgeSBT is SBT, ScrollBadgeNoExpiry { 16 | /// @dev Creates a new ScrollBadgeSBT instance. 17 | /// @param name_ The ERC721 token name. 18 | /// @param symbol_ The ERC721 token symbol. 19 | constructor(string memory name_, string memory symbol_) SBT(name_, symbol_) { 20 | // empty 21 | } 22 | 23 | /// @inheritdoc ScrollBadge 24 | function onIssueBadge(Attestation calldata attestation) internal virtual override returns (bool) { 25 | if (!super.onIssueBadge(attestation)) { 26 | return false; 27 | } 28 | 29 | uint256 tokenId = uid2TokenId(attestation.uid); 30 | _safeMint(attestation.recipient, tokenId); 31 | 32 | return true; 33 | } 34 | 35 | /// @inheritdoc ScrollBadge 36 | function onRevokeBadge(Attestation calldata attestation) internal virtual override returns (bool) { 37 | if (!super.onRevokeBadge(attestation)) { 38 | return false; 39 | } 40 | 41 | uint256 tokenId = uid2TokenId(attestation.uid); 42 | _burn(tokenId); 43 | 44 | return true; 45 | } 46 | 47 | /// @notice Converts an ERC721 token ID into a badge attestation UID. 48 | /// @param tokenId The ERC721 token id. 49 | /// @return The badge attestation UID. 50 | function tokenId2Uid(uint256 tokenId) public pure returns (bytes32) { 51 | return bytes32(tokenId); 52 | } 53 | 54 | /// @notice Converts a badge attestation UID into an ERC721 token ID. 55 | /// @param uri The badge attestation UID. 56 | /// @return The ERC721 token id. 57 | function uid2TokenId(bytes32 uri) public pure returns (uint256) { 58 | return uint256(uri); 59 | } 60 | 61 | /// @inheritdoc ERC721 62 | function tokenURI(uint256 tokenId) public view virtual override (ERC721) returns (string memory) { 63 | bytes32 uid = bytes32(tokenId); 64 | return badgeTokenURI(uid); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/src/attest-server.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config.js'; 2 | 3 | import { EIP712Proxy } from '@ethereum-attestation-service/eas-sdk/dist/eip712-proxy.js'; 4 | import { ethers } from 'ethers'; 5 | import express from 'express'; 6 | 7 | import { createBadge, normalizeAddress } from './lib.js'; 8 | import { badges } from './badges.js'; 9 | 10 | const app = express(); 11 | const provider = new ethers.JsonRpcProvider(process.env.RPC_ENDPOINT); 12 | const signer = new ethers.Wallet(process.env.SIGNER_PRIVATE_KEY).connect(provider); 13 | 14 | // example query: 15 | // curl 'localhost:3000/api/check?badge=0x30C98067517f8ee38e748A3aF63429974103Ea6B&recipient=0x0000000000000000000000000000000000000001' 16 | app.get('/api/check', async (req, res) => { 17 | const { badge: badgeAddress, recipient } = req.query; 18 | 19 | if (!recipient) return res.json({ code: 0, message: 'missing query parameter "recipient"' }); 20 | if (!badgeAddress) return res.json({ code: 0, message: 'missing parameter "badge"' }); 21 | const badge = badges[normalizeAddress(badgeAddress)]; 22 | 23 | if (!badge) return res.json({ code: 0, message: `unknown badge "${address}"` }); 24 | const eligibility = await badge.isEligible(recipient); 25 | if (!eligibility) return res.json({ code: 0, message: 'why the recipient is not eligible', eligibility: false }); 26 | 27 | res.json({ code: 1, message: 'success', eligibility: true }); 28 | }); 29 | 30 | // example query: 31 | // curl 'localhost:3000/api/claim?badge=0x30C98067517f8ee38e748A3aF63429974103Ea6B&recipient=0x0000000000000000000000000000000000000001' 32 | app.get('/api/claim', async (req, res) => { 33 | const { badge: badgeAddress, recipient } = req.query; 34 | 35 | if (!recipient) return res.json({ code: 0, message: 'missing query parameter "recipient"' }); 36 | if (!badgeAddress) return res.json({ code: 0, message: 'missing parameter "badge"' }); 37 | 38 | const badge = badges[normalizeAddress(badgeAddress)]; 39 | 40 | if (!badge) return res.json({ code: 0, message: `unknown badge "${badgeAddress}"` }); 41 | const eligibility = await badge.isEligible(recipient); 42 | if (!eligibility) return res.json({ code: 0, message: 'not eligible' }); 43 | 44 | const proxy = new EIP712Proxy(badge.proxy); 45 | 46 | const attestation = await createBadge({ 47 | badge: badge.address, 48 | recipient, 49 | payload: await badge.createPayload(), 50 | proxy, 51 | signer, 52 | }); 53 | 54 | const tx = await proxy.contract.attestByDelegation.populateTransaction(attestation); 55 | res.json({ code: 1, message: 'success', tx }); 56 | }); 57 | 58 | // Start the server 59 | app.listen(process.env.EXPRESS_SERVER_PORT, () => { 60 | console.log(`Server is running on port ${process.env.EXPRESS_SERVER_PORT}`); 61 | }); 62 | -------------------------------------------------------------------------------- /docs/official-badges/scroll-origins-badge.md: -------------------------------------------------------------------------------- 1 | # Canvas Origins Badge 2 | 3 | In the examples on this page, we use the configurations from [deployments.md](./deployments.md), as well as the following values: 4 | 5 | ```bash 6 | # Scroll Origins NFT addresses 7 | SCROLL_MAINNET_ORIGINS_V1_ADDRESS="0x74670A3998d9d6622E32D0847fF5977c37E0eC91" 8 | SCROLL_MAINNET_ORIGINS_V2_ADDRESS="0x42bCaCb8D24Ba588cab8Db0BB737DD2eFca408EC" 9 | 10 | SCROLL_SEPOLIA_ORIGINS_V2_ADDRESS="0xDd7d857F570B0C211abfe05cd914A85BefEC2464" 11 | 12 | # Badge address 13 | SCROLL_MAINNET_ORIGINS_BADGE_ADDRESS="0x2dBce60ebeAafb77e5472308f432F78aC3AE07d9" 14 | 15 | SCROLL_SEPOLIA_ORIGINS_BADGE_ADDRESS="0x2A3aC1337845f8C02d2dD7f80Dada22f01b569f9" 16 | ``` 17 | 18 | In these examples, we will assume that the user's address is `0x58DB79a596Bf46D400C14672084a145aed08e19b`. 19 | 20 | ### How to check eligibility? 21 | 22 | The Scroll Origin NFT's eligibility has two components: 23 | 24 | 1. The user owns a Scroll Origins NFT token. Check this using `tokenOfOwnerByIndex`. 25 | 26 | ```bash 27 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_ORIGINS_V2_ADDRESS" "tokenOfOwnerByIndex(address,uint256)(uint256)" 0x58DB79a596Bf46D400C14672084a145aed08e19b 0 28 | 10000008 29 | ``` 30 | 31 | 2. The user has not minted a Scroll Origins badge yet. 32 | 33 | ```bash 34 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_ORIGINS_BADGE_ADDRESS" "hasBadge(address)(bool)" "0x58DB79a596Bf46D400C14672084a145aed08e19b" 35 | false 36 | ``` 37 | 38 | 39 | ### How to mint a Scroll Origins badge? 40 | 41 | A Scroll Origins badge can be minted from the frontend, no backend support is required. 42 | 43 | First, find the user's token ID. 44 | 45 | ```bash 46 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_ORIGINS_V2_ADDRESS" "tokenOfOwnerByIndex(address,uint256)(uint256)" 0x58DB79a596Bf46D400C14672084a145aed08e19b 0 47 | 10000008 48 | ``` 49 | 50 | The user's Origins NFT is token `10000008` on contract `$SCROLL_SEPOLIA_ORIGINS_V2_ADDRESS`. 51 | 52 | Next, we mint a badge directly through EAS. 53 | 54 | ```bash 55 | # encode Scroll Origins badge payload 56 | # schema: "address originsTokenAddress, uint256 originsTokenId" 57 | > ORIGINS_BADGE_PAYLOAD=$(cast abi-encode "abc(address,uint256)" "$SCROLL_SEPOLIA_ORIGINS_V2_ADDRESS" "10000008") 58 | 59 | # encode badge payload 60 | > BADGE_PAYLOAD=$(cast abi-encode "abc(address,bytes)" "$SCROLL_SEPOLIA_ORIGINS_BADGE_ADDRESS" "$ORIGINS_BADGE_PAYLOAD") 61 | 62 | # attest 63 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_EAS_ADDRESS" "attest((bytes32,(address,uint64,bool,bytes32,bytes,uint256)))" "($SCROLL_SEPOLIA_BADGE_SCHEMA,(0x58DB79a596Bf46D400C14672084a145aed08e19b,0,false,0x0000000000000000000000000000000000000000000000000000000000000000,$BADGE_PAYLOAD,0))" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY" 64 | ``` 65 | 66 | Note: only the recipient (`0x58DB79a596Bf46D400C14672084a145aed08e19b` in this case) can mint this badge. 67 | -------------------------------------------------------------------------------- /src/badge/examples/ScrollBadgeLevels.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; 8 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 9 | 10 | import {ScrollBadgeAccessControl} from "../extensions/ScrollBadgeAccessControl.sol"; 11 | import {ScrollBadgeCustomPayload} from "../extensions/ScrollBadgeCustomPayload.sol"; 12 | import {ScrollBadgeDefaultURI} from "../extensions/ScrollBadgeDefaultURI.sol"; 13 | import {ScrollBadge} from "../ScrollBadge.sol"; 14 | 15 | string constant SCROLL_BADGE_LEVELS_SCHEMA = "uint8 scrollLevel"; 16 | 17 | function decodePayloadData(bytes memory data) pure returns (uint8) { 18 | return abi.decode(data, (uint8)); 19 | } 20 | 21 | /// @title ScrollBadgeLevels 22 | /// @notice A simple badge that represents the user's level. 23 | contract ScrollBadgeLevels is ScrollBadgeAccessControl, ScrollBadgeCustomPayload, ScrollBadgeDefaultURI { 24 | constructor(address resolver_, string memory _defaultBadgeURI) 25 | ScrollBadge(resolver_) 26 | ScrollBadgeDefaultURI(_defaultBadgeURI) 27 | { 28 | // empty 29 | } 30 | 31 | /// @inheritdoc ScrollBadge 32 | function onIssueBadge(Attestation calldata attestation) 33 | internal 34 | override (ScrollBadge, ScrollBadgeAccessControl, ScrollBadgeCustomPayload) 35 | returns (bool) 36 | { 37 | return super.onIssueBadge(attestation); 38 | } 39 | 40 | /// @inheritdoc ScrollBadge 41 | function onRevokeBadge(Attestation calldata attestation) 42 | internal 43 | override (ScrollBadge, ScrollBadgeAccessControl, ScrollBadgeCustomPayload) 44 | returns (bool) 45 | { 46 | return super.onRevokeBadge(attestation); 47 | } 48 | 49 | /// @inheritdoc ScrollBadgeDefaultURI 50 | function getBadgeTokenURI(bytes32 uid) internal view override returns (string memory) { 51 | uint8 level = getCurrentLevel(uid); 52 | string memory name = string(abi.encode("Scroll Level #", Strings.toString(level))); 53 | string memory description = "Scroll Level Badge"; 54 | string memory image = ""; // IPFS, HTTP, or data URL 55 | string memory tokenUriJson = Base64.encode( 56 | abi.encodePacked('{"name":"', name, '", "description":"', description, ', "image": "', image, '"}') 57 | ); 58 | return string(abi.encodePacked("data:application/json;base64,", tokenUriJson)); 59 | } 60 | 61 | /// @inheritdoc ScrollBadgeCustomPayload 62 | function getSchema() public pure override returns (string memory) { 63 | return SCROLL_BADGE_LEVELS_SCHEMA; 64 | } 65 | 66 | function getCurrentLevel(bytes32 uid) public view returns (uint8) { 67 | Attestation memory badge = getAndValidateBadge(uid); 68 | bytes memory payload = getPayload(badge); 69 | (uint8 level) = decodePayloadData(payload); 70 | return level; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/AttesterProxy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import { 6 | EIP712Proxy, 7 | AttestationRequest, 8 | RevocationRequest, 9 | DelegatedProxyAttestationRequest 10 | } from "@eas/contracts/eip712/proxy/EIP712Proxy.sol"; 11 | 12 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 13 | 14 | import {AccessDenied} from "@eas/contracts/Common.sol"; 15 | import {IEAS, Attestation} from "@eas/contracts/IEAS.sol"; 16 | 17 | /// @title AttesterProxy 18 | /// @notice An EIP712 proxy that allows only specific addresses to attest. 19 | /// Based on PermissionedEIP712Proxy in the EAS repo. 20 | contract AttesterProxy is EIP712Proxy, Ownable { 21 | // The global EAS contract. 22 | IEAS private immutable _eas; 23 | 24 | // Authorized badge attester accounts. 25 | mapping(address => bool) public isAttester; 26 | 27 | /// @dev Creates a new PermissionedEIP712Proxy instance. 28 | /// @param eas The address of the global EAS contract. 29 | constructor(IEAS eas) EIP712Proxy(eas, "AttesterProxy") { 30 | _eas = eas; 31 | } 32 | 33 | /// @notice Enables or disables a given attester. 34 | /// @param attester The attester address. 35 | /// @param enable True if enable, false if disable. 36 | function toggleAttester(address attester, bool enable) external onlyOwner { 37 | isAttester[attester] = enable; 38 | } 39 | 40 | /// @inheritdoc EIP712Proxy 41 | function attestByDelegation(DelegatedProxyAttestationRequest calldata delegatedRequest) 42 | public 43 | payable 44 | override 45 | returns (bytes32) 46 | { 47 | // Ensure that only the owner is allowed to delegate attestations. 48 | _verifyAttester(delegatedRequest.attester); 49 | 50 | // Ensure that only the recipient can submit delegated attestation transactions. 51 | if (msg.sender != delegatedRequest.data.recipient) { 52 | revert AccessDenied(); 53 | } 54 | 55 | return super.attestByDelegation(delegatedRequest); 56 | } 57 | 58 | /// @notice Create attestation through the proxy. 59 | /// @param request The arguments of the attestation request. 60 | /// @return The UID of the new attestation. 61 | function attest(AttestationRequest calldata request) external returns (bytes32) { 62 | _verifyAttester(msg.sender); 63 | return _eas.attest(request); 64 | } 65 | 66 | /// @notice Revoke attestation through the proxy. 67 | /// @param request The arguments of the revocation request. 68 | function revoke(RevocationRequest calldata request) external { 69 | _verifyAttester(msg.sender); 70 | _eas.revoke(request); 71 | } 72 | 73 | /// @dev Ensures that only the allowed attester can attest. 74 | /// @param attester The attester to verify. 75 | function _verifyAttester(address attester) private view { 76 | if (!isAttester[attester]) { 77 | revert AccessDenied(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/ScrollBadgeTestBase.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Test} from "forge-std/Test.sol"; 6 | 7 | import {EAS} from "@eas/contracts/EAS.sol"; 8 | import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 9 | import {ISchemaResolver} from "@eas/contracts/resolver/ISchemaResolver.sol"; 10 | import {SchemaRegistry, ISchemaRegistry} from "@eas/contracts/SchemaRegistry.sol"; 11 | 12 | import { 13 | IEAS, 14 | AttestationRequest, 15 | AttestationRequestData, 16 | RevocationRequest, 17 | RevocationRequestData 18 | } from "@eas/contracts/IEAS.sol"; 19 | 20 | import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; 21 | 22 | import {ScrollBadgeResolver} from "../src/resolver/ScrollBadgeResolver.sol"; 23 | import {ProfileRegistry} from "../src/profile/ProfileRegistry.sol"; 24 | 25 | contract ScrollBadgeTestBase is Test { 26 | ISchemaRegistry internal registry; 27 | IEAS internal eas; 28 | ScrollBadgeResolver internal resolver; 29 | 30 | bytes32 schema; 31 | 32 | address internal constant alice = address(1); 33 | address internal constant bob = address(2); 34 | 35 | address internal constant PROXY_ADMIN_ADDRESS = 0x2000000000000000000000000000000000000000; 36 | 37 | function setUp() public virtual { 38 | // EAS infra 39 | registry = new SchemaRegistry(); 40 | eas = new EAS(registry); 41 | 42 | // Scroll components 43 | // no need to initialize the registry, since resolver 44 | // only uses it to see if a profile has been minted or not. 45 | address profileRegistry = address(new ProfileRegistry()); 46 | 47 | address resolverImpl = address(new ScrollBadgeResolver(address(eas), profileRegistry)); 48 | address resolverProxy = address(new TransparentUpgradeableProxy(resolverImpl, PROXY_ADMIN_ADDRESS, "")); 49 | resolver = ScrollBadgeResolver(payable(resolverProxy)); 50 | resolver.initialize(); 51 | 52 | schema = resolver.schema(); 53 | } 54 | 55 | function _attest(address badge, bytes memory payload, address recipient) internal returns (bytes32) { 56 | bytes memory attestationData = abi.encode(badge, payload); 57 | 58 | AttestationRequestData memory _attData = AttestationRequestData({ 59 | recipient: recipient, 60 | expirationTime: NO_EXPIRATION_TIME, 61 | revocable: true, 62 | refUID: EMPTY_UID, 63 | data: attestationData, 64 | value: 0 65 | }); 66 | 67 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 68 | 69 | return eas.attest(_req); 70 | } 71 | 72 | function _revoke(bytes32 uid) internal { 73 | RevocationRequestData memory _data = RevocationRequestData({uid: uid, value: 0}); 74 | 75 | RevocationRequest memory _req = RevocationRequest({schema: schema, data: _data}); 76 | 77 | eas.revoke(_req); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/badge/examples/EthereumYearBadge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 8 | 9 | import {ScrollBadge} from "../ScrollBadge.sol"; 10 | import {ScrollBadgeAccessControl} from "../extensions/ScrollBadgeAccessControl.sol"; 11 | import {ScrollBadgeCustomPayload} from "../extensions/ScrollBadgeCustomPayload.sol"; 12 | import {ScrollBadgeDefaultURI} from "../extensions/ScrollBadgeDefaultURI.sol"; 13 | import {ScrollBadgeNoExpiry} from "../extensions/ScrollBadgeNoExpiry.sol"; 14 | import {ScrollBadgeNonRevocable} from "../extensions/ScrollBadgeNonRevocable.sol"; 15 | import {ScrollBadgeSingleton} from "../extensions/ScrollBadgeSingleton.sol"; 16 | 17 | string constant ETHEREUM_YEAR_BADGE_SCHEMA = "uint256 year"; 18 | 19 | function decodePayloadData(bytes memory data) pure returns (uint256) { 20 | return abi.decode(data, (uint256)); 21 | } 22 | 23 | /// @title EthereumYearBadge 24 | /// @notice A badge that represents the year of the user's first transaction on Ethereum. 25 | contract EthereumYearBadge is 26 | ScrollBadgeAccessControl, 27 | ScrollBadgeCustomPayload, 28 | ScrollBadgeDefaultURI, 29 | ScrollBadgeNoExpiry, 30 | ScrollBadgeNonRevocable, 31 | ScrollBadgeSingleton 32 | { 33 | /// @notice The base token URI. 34 | string public baseTokenURI; 35 | 36 | constructor(address resolver_, string memory baseTokenURI_) 37 | ScrollBadge(resolver_) 38 | ScrollBadgeDefaultURI(baseTokenURI_) 39 | { 40 | // empty 41 | } 42 | 43 | /// @notice Update the base token URI. 44 | /// @param baseTokenURI_ The new base token URI. 45 | function updateBaseTokenURI(string memory baseTokenURI_) external onlyOwner { 46 | defaultBadgeURI = baseTokenURI_; 47 | } 48 | 49 | /// @inheritdoc ScrollBadge 50 | function onIssueBadge(Attestation calldata attestation) 51 | internal 52 | override ( 53 | ScrollBadge, 54 | ScrollBadgeAccessControl, 55 | ScrollBadgeCustomPayload, 56 | ScrollBadgeNoExpiry, 57 | ScrollBadgeNonRevocable, 58 | ScrollBadgeSingleton 59 | ) 60 | returns (bool) 61 | { 62 | return super.onIssueBadge(attestation); 63 | } 64 | 65 | /// @inheritdoc ScrollBadge 66 | function onRevokeBadge(Attestation calldata attestation) 67 | internal 68 | override ( 69 | ScrollBadge, ScrollBadgeAccessControl, ScrollBadgeCustomPayload, ScrollBadgeNoExpiry, ScrollBadgeSingleton 70 | ) 71 | returns (bool) 72 | { 73 | return super.onRevokeBadge(attestation); 74 | } 75 | 76 | /// @inheritdoc ScrollBadgeDefaultURI 77 | function getBadgeTokenURI(bytes32 uid) internal view override returns (string memory) { 78 | Attestation memory attestation = getAndValidateBadge(uid); 79 | bytes memory payload = getPayload(attestation); 80 | uint256 year = decodePayloadData(payload); 81 | 82 | return string(abi.encodePacked(defaultBadgeURI, Strings.toString(year), ".json")); 83 | } 84 | 85 | /// @inheritdoc ScrollBadgeCustomPayload 86 | function getSchema() public pure override returns (string memory) { 87 | return ETHEREUM_YEAR_BADGE_SCHEMA; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/interfaces/IProfileRegistry.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | interface IProfileRegistry { 6 | /** 7 | * 8 | * Events * 9 | * 10 | */ 11 | 12 | /// @notice Emitted when a new profile is minted. 13 | /// @param account The address of account who minted the profile. 14 | /// @param profile The address of profile minted. 15 | /// @param referrer The address of referrer. 16 | event MintProfile(address indexed account, address indexed profile, address indexed referrer); 17 | 18 | /// @notice Emitted when profile register username. 19 | /// @param profile The address of profile. 20 | /// @param username The username registered. 21 | event RegisterUsername(address indexed profile, string username); 22 | 23 | /// @notice Emitted when profile unregister username. 24 | /// @param profile The address of profile. 25 | /// @param username The username unregistered. 26 | event UnregisterUsername(address indexed profile, string username); 27 | 28 | /// @notice Emitted when the default profile avatar is updated. 29 | /// @param oldAvatar The token URI of the previous avatar. 30 | /// @param newAvatar The token URI of the current avatar. 31 | event UpdateDefaultProfileAvatar(string oldAvatar, string newAvatar); 32 | 33 | /// @dev Emitted when the profile implementation is updated. 34 | /// @param oldImplementation The address of previous profile implementation. 35 | /// @param newImplementation The address of current profile implementation. 36 | event UpdateProfileImplementation(address indexed oldImplementation, address indexed newImplementation); 37 | 38 | /// @dev Emitted when the referral signer is updated. 39 | /// @param oldSigner The address of previous signer. 40 | /// @param newSigner The address of current signer. 41 | event UpdateSigner(address indexed oldSigner, address indexed newSigner); 42 | 43 | /// @dev Emitted when the mint fee treasury is updated. 44 | /// @param oldTreasury The address of previous treasury. 45 | /// @param newTreasury The address of current treasury. 46 | event UpdateTreasury(address indexed oldTreasury, address indexed newTreasury); 47 | 48 | /** 49 | * 50 | * Public View Functions * 51 | * 52 | */ 53 | 54 | /// @notice Check whether the profile is minted in this contract. 55 | /// @param profile The address of profile to check. 56 | function isProfileMinted(address profile) external view returns (bool); 57 | 58 | /// @notice Check whether the username is used by other profile. 59 | /// @param username The username to query. 60 | function isUsernameUsed(string calldata username) external view returns (bool); 61 | 62 | /// @notice Calculate the address of profile with given account address. 63 | /// @param account The address of account to query. 64 | function getProfile(address account) external view returns (address); 65 | 66 | /// @notice Return the tokenURI for default profile avatar. 67 | function getDefaultProfileAvatar() external view returns (string memory); 68 | 69 | /** 70 | * 71 | * Public Mutating Functions * 72 | * 73 | */ 74 | 75 | /// @notice Register an username. 76 | /// @param username The username to register. 77 | function registerUsername(string memory username) external; 78 | 79 | /// @notice Unregister an username. 80 | /// @param username The username to unregister. 81 | function unregisterUsername(string memory username) external; 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scroll Canvas Contracts 2 | 3 | [![test](https://github.com/scroll-tech/canvas-contracts/actions/workflows/contracts.yml/badge.svg)](https://github.com/scroll-tech/canvas-contracts/actions/workflows/contracts.yml) 4 | 5 | ## Welcome to Scroll Canvas 6 | 7 | We are thrilled to have you join us in building unique discoveries with [Scroll Canvas](https://scroll.io/canvas), a new product designed for ecosystem projects to interact with users in a more tailored way. 8 | 9 | Try Canvas at [scroll.io/canvas](https://scroll.io/canvas) 10 | 11 | ## Overview 12 | 13 | **Scroll Canvas** allows users to showcase on-chain credentials, status, and achievements called **Badges** issued and collected across the Scroll ecosystem. 14 | Users can mint a non-transferable and unique personal persona to collect and display their **Badges**. 15 | 16 | ### Key Features 17 | 18 | - **Canvas**: Each Canvas is a smart contract minted through the `ProfileRegistry` contract by the user on Scroll’s website. 19 | - **Badges**: Attestations of achievements and traits verified through the [Ethereum Attestation Service](https://docs.attest.sh/docs/welcome) (EAS), issued by different projects and the Scroll Foundation. 20 | Badges are wallet-bound and non-transferable. 21 | 22 | Differences between attestations and NFTs: 23 | 24 | | Attestation | NFT | 25 | | --- | --- | 26 | | Witness Proofs | Tokenized Assets | 27 | | Non-transferable | Transferable | 28 | | Recorded on disk (blockchain history) | Recorded in memory (blockchain states) | 29 | | Prove ownership at a point in time | Exercise custodianship of an asset | 30 | 31 | ## Developer Quickstart 32 | 33 | Visit the [Developer Documentation](./docs) in this repo to learn more about Canvas. 34 | 35 | See [Deployments](./docs/deployments.md) for the official Canvas contract addresses. 36 | 37 | See the [Integration Guide](https://scrollzkp.notion.site/Introducing-Scroll-Canvas-Badge-Integration-Guide-8656463ab63b42e8baf924763ed8c9d5) for more information. 38 | 39 | ## Support 40 | 41 | For questions regarding Canvas and custom badge development, please join [Scroll dev support channel](https://discord.com/channels/853955156100907018/1028102371894624337) on Discord. 42 | 43 | ## Running the Code 44 | 45 | ### Node.js 46 | 47 | First install [`Node.js`](https://nodejs.org/en) and [`npm`](https://www.npmjs.com/). 48 | Run the following command to install [`yarn`](https://classic.yarnpkg.com/en/): 49 | 50 | ```bash 51 | npm install --global yarn 52 | ``` 53 | 54 | ### Foundry 55 | 56 | Install `foundryup`, the Foundry toolchain installer: 57 | 58 | ```bash 59 | curl -L https://foundry.paradigm.xyz | bash 60 | ``` 61 | 62 | If you do not want to use the redirect, feel free to manually download the `foundryup` installation script from [here](https://raw.githubusercontent.com/foundry-rs/foundry/master/foundryup/foundryup). Then, run `foundryup` in a new terminal session or after reloading `PATH`. 63 | 64 | Other ways to install Foundry can be found [here](https://github.com/foundry-rs/foundry#installation). 65 | 66 | ### Install Dependencies 67 | 68 | Run the following command to install all dependencies locally. 69 | 70 | ``` 71 | yarn 72 | ``` 73 | 74 | ### Run Contract Tests 75 | 76 | Run the following command to run the contract tests. 77 | 78 | ``` 79 | yarn test 80 | ``` 81 | 82 | ## Contributing 83 | 84 | We welcome community contributions to this repository. 85 | For larger changes, please [open an issue](https://github.com/scroll-tech/canvas-contracts/issues/new/choose) and discuss with the team before submitting code changes. 86 | 87 | ## License 88 | 89 | Scroll Monorepo is licensed under the [MIT](./LICENSE) license. 90 | -------------------------------------------------------------------------------- /src/badge/ScrollBadge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {decodeBadgeData} from "../Common.sol"; 8 | import {IScrollBadge} from "../interfaces/IScrollBadge.sol"; 9 | import {IScrollBadgeResolver} from "../interfaces/IScrollBadgeResolver.sol"; 10 | import {AttestationBadgeMismatch, Unauthorized} from "../Errors.sol"; 11 | 12 | /// @title ScrollBadge 13 | /// @notice This contract implements the basic functionalities of a Scroll badge. 14 | /// It serves as the base contract for more complex badge functionalities. 15 | abstract contract ScrollBadge is IScrollBadge { 16 | // The global Scroll badge resolver contract. 17 | address public immutable resolver; 18 | 19 | // wallet address => badge count 20 | mapping(address => uint256) private _userBadgeCount; 21 | 22 | /// @dev Creates a new ScrollBadge instance. 23 | /// @param resolver_ The address of the global Scroll badge resolver contract. 24 | constructor(address resolver_) { 25 | resolver = resolver_; 26 | } 27 | 28 | /// @inheritdoc IScrollBadge 29 | function issueBadge(Attestation calldata attestation) public returns (bool) { 30 | // only callable from resolver 31 | if (msg.sender != address(resolver)) { 32 | revert Unauthorized(); 33 | } 34 | 35 | // delegate logic to subcontract 36 | if (!onIssueBadge(attestation)) { 37 | return false; 38 | } 39 | 40 | _userBadgeCount[attestation.recipient] += 1; 41 | 42 | emit IssueBadge(attestation.uid); 43 | return true; 44 | } 45 | 46 | /// @inheritdoc IScrollBadge 47 | function revokeBadge(Attestation calldata attestation) public returns (bool) { 48 | // only callable from resolver 49 | if (msg.sender != address(resolver)) { 50 | revert Unauthorized(); 51 | } 52 | 53 | // delegate logic to subcontract 54 | if (!onRevokeBadge(attestation)) { 55 | return false; 56 | } 57 | 58 | _userBadgeCount[attestation.recipient] -= 1; 59 | 60 | emit RevokeBadge(attestation.uid); 61 | return true; 62 | } 63 | 64 | /// @notice A resolver callback that should be implemented by child contracts. 65 | /// @param {attestation} The new attestation. 66 | /// @return Whether the attestation is valid. 67 | function onIssueBadge(Attestation calldata /*attestation*/ ) internal virtual returns (bool) { 68 | return true; 69 | } 70 | 71 | /// @notice A resolver callback that should be implemented by child contracts. 72 | /// @param {attestation} The existing attestation to be revoked. 73 | /// @return Whether the attestation can be revoked. 74 | function onRevokeBadge(Attestation calldata /*attestation*/ ) internal virtual returns (bool) { 75 | return true; 76 | } 77 | 78 | /// @inheritdoc IScrollBadge 79 | function getAndValidateBadge(bytes32 uid) public view returns (Attestation memory) { 80 | Attestation memory attestation = IScrollBadgeResolver(resolver).getAndValidateBadge(uid); 81 | 82 | (address badge,) = decodeBadgeData(attestation.data); 83 | 84 | if (badge != address(this)) { 85 | revert AttestationBadgeMismatch(uid); 86 | } 87 | 88 | return attestation; 89 | } 90 | 91 | /// @inheritdoc IScrollBadge 92 | function badgeTokenURI(bytes32 uid) public view virtual returns (string memory); 93 | 94 | /// @inheritdoc IScrollBadge 95 | function hasBadge(address user) public view virtual returns (bool) { 96 | return _userBadgeCount[user] > 0; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/badge/examples/ScrollBadgeTokenOwner.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; 8 | import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 9 | 10 | import {ScrollBadge} from "../ScrollBadge.sol"; 11 | import {ScrollBadgeCustomPayload} from "../extensions/ScrollBadgeCustomPayload.sol"; 12 | import {ScrollBadgeDefaultURI} from "../extensions/ScrollBadgeDefaultURI.sol"; 13 | import {ScrollBadgeEligibilityCheck} from "../extensions/ScrollBadgeEligibilityCheck.sol"; 14 | import {ScrollBadgeSelfAttest} from "../extensions/ScrollBadgeSelfAttest.sol"; 15 | import {ScrollBadgeSingleton} from "../extensions/ScrollBadgeSingleton.sol"; 16 | import {Unauthorized} from "../../Errors.sol"; 17 | 18 | string constant SCROLL_BADGE_NFT_OWNER_SCHEMA = "address tokenAddress, uint256 tokenId"; 19 | 20 | function decodePayloadData(bytes memory data) pure returns (address, uint256) { 21 | return abi.decode(data, (address, uint256)); 22 | } 23 | 24 | /// @title ScrollBadgeTokenOwner 25 | /// @notice A simple badge that attests that the user owns a specific NFT. 26 | contract ScrollBadgeTokenOwner is 27 | ScrollBadgeCustomPayload, 28 | ScrollBadgeDefaultURI, 29 | ScrollBadgeEligibilityCheck, 30 | ScrollBadgeSelfAttest, 31 | ScrollBadgeSingleton 32 | { 33 | error IncorrectBadgeOwner(); 34 | 35 | mapping(address => bool) public isTokenAllowed; 36 | 37 | constructor(address resolver_, string memory _defaultBadgeURI, address[] memory tokens_) 38 | ScrollBadge(resolver_) 39 | ScrollBadgeDefaultURI(_defaultBadgeURI) 40 | { 41 | for (uint256 i = 0; i < tokens_.length; ++i) { 42 | isTokenAllowed[tokens_[i]] = true; 43 | } 44 | } 45 | 46 | /// @inheritdoc ScrollBadge 47 | function onIssueBadge(Attestation calldata attestation) 48 | internal 49 | override (ScrollBadge, ScrollBadgeCustomPayload, ScrollBadgeSelfAttest, ScrollBadgeSingleton) 50 | returns (bool) 51 | { 52 | if (!super.onIssueBadge(attestation)) { 53 | return false; 54 | } 55 | 56 | // check that badge payload attestation is correct 57 | bytes memory payload = getPayload(attestation); 58 | (address tokenAddress, uint256 tokenId) = decodePayloadData(payload); 59 | 60 | if (!isTokenAllowed[tokenAddress]) { 61 | revert Unauthorized(); 62 | } 63 | 64 | if (IERC721(tokenAddress).ownerOf(tokenId) != attestation.recipient) { 65 | revert IncorrectBadgeOwner(); 66 | } 67 | 68 | return true; 69 | } 70 | 71 | /// @inheritdoc ScrollBadge 72 | function onRevokeBadge(Attestation calldata attestation) 73 | internal 74 | override (ScrollBadge, ScrollBadgeCustomPayload, ScrollBadgeSelfAttest, ScrollBadgeSingleton) 75 | returns (bool) 76 | { 77 | return super.onRevokeBadge(attestation); 78 | } 79 | 80 | /// @inheritdoc ScrollBadgeDefaultURI 81 | function getBadgeTokenURI(bytes32 uid) internal view override returns (string memory) { 82 | Attestation memory attestation = getAndValidateBadge(uid); 83 | bytes memory payload = getPayload(attestation); 84 | (address tokenAddress, uint256 tokenId) = decodePayloadData(payload); 85 | return IERC721Metadata(tokenAddress).tokenURI(tokenId); 86 | } 87 | 88 | /// @inheritdoc ScrollBadgeCustomPayload 89 | function getSchema() public pure override returns (string memory) { 90 | return SCROLL_BADGE_NFT_OWNER_SCHEMA; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /script/DeployCanvasContracts.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.19; 3 | 4 | import {Script} from "forge-std/Script.sol"; 5 | import {console} from "forge-std/console.sol"; 6 | 7 | import {EAS} from "@eas/contracts/EAS.sol"; 8 | 9 | import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; 10 | 11 | import { 12 | ITransparentUpgradeableProxy, 13 | TransparentUpgradeableProxy 14 | } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; 15 | 16 | import {EmptyContract} from "../src/misc/EmptyContract.sol"; 17 | import {Profile} from "../src/profile/Profile.sol"; 18 | import {ProfileRegistry} from "../src/profile/ProfileRegistry.sol"; 19 | import {ScrollBadgeResolver} from "../src/resolver/ScrollBadgeResolver.sol"; 20 | 21 | contract DeployCanvasContracts is Script { 22 | uint256 DEPLOYER_PRIVATE_KEY = vm.envUint("DEPLOYER_PRIVATE_KEY"); 23 | 24 | address SIGNER_ADDRESS = vm.envAddress("SIGNER_ADDRESS"); 25 | address TREASURY_ADDRESS = vm.envAddress("TREASURY_ADDRESS"); 26 | 27 | address EAS_ADDRESS = vm.envAddress("EAS_ADDRESS"); 28 | 29 | function run() external { 30 | vm.startBroadcast(DEPLOYER_PRIVATE_KEY); 31 | 32 | // deploy proxy admin 33 | ProxyAdmin proxyAdmin = new ProxyAdmin(); 34 | 35 | // deploy profile registry placeholder 36 | address placeholder = address(new EmptyContract()); 37 | address profileRegistryProxy = address(new TransparentUpgradeableProxy(placeholder, address(proxyAdmin), "")); 38 | 39 | // deploy Scroll badge resolver 40 | address resolverImpl = address(new ScrollBadgeResolver(EAS_ADDRESS, profileRegistryProxy)); 41 | address resolverProxy = address(new TransparentUpgradeableProxy(resolverImpl, address(proxyAdmin), "")); 42 | ScrollBadgeResolver resolver = ScrollBadgeResolver(payable(resolverProxy)); 43 | resolver.initialize(); 44 | 45 | bytes32 schema = resolver.schema(); 46 | 47 | // deploy profile implementation and upgrade registry 48 | Profile profileImpl = new Profile(address(resolver)); 49 | ProfileRegistry profileRegistryImpl = new ProfileRegistry(); 50 | proxyAdmin.upgrade(ITransparentUpgradeableProxy(profileRegistryProxy), address(profileRegistryImpl)); 51 | ProfileRegistry(profileRegistryProxy).initialize(TREASURY_ADDRESS, SIGNER_ADDRESS, address(profileImpl)); 52 | 53 | // misc 54 | bytes32[] memory blacklist = new bytes32[](1); 55 | blacklist[0] = keccak256(bytes("vpn")); 56 | ProfileRegistry(profileRegistryProxy).blacklistUsername(blacklist); 57 | 58 | // log addresses 59 | logAddress("DEPLOYER_ADDRESS", vm.addr(DEPLOYER_PRIVATE_KEY)); 60 | logAddress("SIGNER_ADDRESS", SIGNER_ADDRESS); 61 | logAddress("TREASURY_ADDRESS", TREASURY_ADDRESS); 62 | logAddress("EAS_ADDRESS", EAS_ADDRESS); 63 | logAddress("SCROLL_PROFILE_REGISTRY_PROXY_ADMIN_ADDRESS", address(proxyAdmin)); 64 | logAddress("SCROLL_PROFILE_REGISTRY_PROXY_CONTRACT_ADDRESS", address(profileRegistryProxy)); 65 | logAddress("SCROLL_BADGE_RESOLVER_CONTRACT_ADDRESS", address(resolver)); 66 | logBytes32("SCROLL_BADGE_SCHEMA_UID", schema); 67 | logAddress("SCROLL_PROFILE_IMPLEMENTATION_CONTRACT_ADDRESS", address(profileImpl)); 68 | logAddress("SCROLL_PROFILE_REGISTRY_IMPLEMENTATION_CONTRACT_ADDRESS", address(profileRegistryImpl)); 69 | 70 | vm.stopBroadcast(); 71 | } 72 | 73 | function logAddress(string memory name, address addr) internal view { 74 | console.log(string(abi.encodePacked(name, "=", vm.toString(address(addr))))); 75 | } 76 | 77 | function logBytes32(string memory name, bytes32 data) internal view { 78 | console.log(string(abi.encodePacked(name, "=", vm.toString(data)))); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/ScrollBadge.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 8 | import {Attestation, AttestationRequest, AttestationRequestData} from "@eas/contracts/IEAS.sol"; 9 | 10 | import {IScrollBadge, ScrollBadge} from "../src/badge/ScrollBadge.sol"; 11 | import {AttestationBadgeMismatch, Unauthorized} from "../src/Errors.sol"; 12 | 13 | contract TestContract is ScrollBadge { 14 | constructor(address resolver_) ScrollBadge(resolver_) {} 15 | 16 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 17 | return ""; 18 | } 19 | } 20 | 21 | contract ScrollBadgeTest is ScrollBadgeTestBase { 22 | ScrollBadge internal badge; 23 | 24 | function setUp() public virtual override { 25 | super.setUp(); 26 | 27 | badge = new TestContract(address(resolver)); 28 | resolver.toggleBadge(address(badge), true); 29 | } 30 | 31 | function testAttestRevoke(address recipient) external { 32 | bytes32 uid = _attest(address(badge), "", recipient); 33 | 34 | bool isValid = eas.isAttestationValid(uid); 35 | assertTrue(isValid); 36 | 37 | bool hasBadge = badge.hasBadge(recipient); 38 | assertTrue(hasBadge); 39 | 40 | _revoke(uid); 41 | bool isValid2 = eas.isAttestationValid(uid); 42 | assertTrue(isValid2); 43 | 44 | Attestation memory attestation = eas.getAttestation(uid); 45 | assertGe(attestation.revocationTime, 0); 46 | 47 | bool hasBadge2 = badge.hasBadge(recipient); 48 | assertFalse(hasBadge2); 49 | } 50 | 51 | function testAttestMultiple(address recipient, uint8 times) external { 52 | vm.assume(times < 10); 53 | 54 | bytes32[] memory uids = new bytes32[](times); 55 | 56 | for (uint256 i = 0; i < times; i++) { 57 | bytes32 uid = _attest(address(badge), "", recipient); 58 | uids[i] = uid; 59 | } 60 | 61 | for (uint256 i = 0; i < times; i++) { 62 | bool hasBadge = badge.hasBadge(recipient); 63 | assertTrue(hasBadge); 64 | 65 | _revoke(uids[i]); 66 | } 67 | 68 | bool hasBadge2 = badge.hasBadge(recipient); 69 | assertFalse(hasBadge2); 70 | } 71 | 72 | function testIssueBadgeOnlyResolver(address notResolver, Attestation memory attestation) external { 73 | vm.assume(notResolver != address(resolver)); 74 | vm.prank(notResolver); 75 | vm.expectRevert(Unauthorized.selector); 76 | badge.issueBadge(attestation); 77 | } 78 | 79 | function testRevokeBadgeOnlyResolver(address notResolver, Attestation memory attestation) external { 80 | vm.assume(notResolver != address(resolver)); 81 | vm.prank(notResolver); 82 | vm.expectRevert(Unauthorized.selector); 83 | badge.revokeBadge(attestation); 84 | } 85 | 86 | function testGetBadge() external { 87 | bytes32 uid = _attest(address(badge), "", alice); 88 | Attestation memory attestation = badge.getAndValidateBadge(uid); 89 | assertEq(attestation.uid, uid); 90 | } 91 | 92 | function testGetWrongBadgeFails() external { 93 | ScrollBadge otherBadge = new TestContract(address(resolver)); 94 | resolver.toggleBadge(address(otherBadge), true); 95 | bytes memory data = abi.encode(otherBadge, ""); 96 | 97 | AttestationRequestData memory _attData = AttestationRequestData({ 98 | recipient: alice, 99 | expirationTime: NO_EXPIRATION_TIME, 100 | revocable: true, 101 | refUID: EMPTY_UID, 102 | data: data, 103 | value: 0 104 | }); 105 | 106 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 107 | 108 | bytes32 uid = eas.attest(_req); 109 | 110 | vm.expectRevert(abi.encodeWithSelector(AttestationBadgeMismatch.selector, uid)); 111 | badge.getAndValidateBadge(uid); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /script/DeployTestContracts.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.19; 3 | 4 | import {Script} from "forge-std/Script.sol"; 5 | import {console} from "forge-std/console.sol"; 6 | 7 | import {SchemaRegistry, ISchemaRegistry} from "@eas/contracts/SchemaRegistry.sol"; 8 | import {EAS} from "@eas/contracts/EAS.sol"; 9 | 10 | import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; 11 | import { 12 | ITransparentUpgradeableProxy, 13 | TransparentUpgradeableProxy 14 | } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; 15 | 16 | import {AttesterProxy} from "../src/AttesterProxy.sol"; 17 | import {ScrollBadgeResolver} from "../src/resolver/ScrollBadgeResolver.sol"; 18 | import {ScrollBadgeSimple} from "../src/badge/examples/ScrollBadgeSimple.sol"; 19 | import {ProfileRegistry} from "../src/profile/ProfileRegistry.sol"; 20 | import {Profile} from "../src/profile/Profile.sol"; 21 | import {ProfileRegistry} from "../src/profile/ProfileRegistry.sol"; 22 | import {EmptyContract} from "../src/misc/EmptyContract.sol"; 23 | 24 | contract DeployTestContracts is Script { 25 | uint256 DEPLOYER_PRIVATE_KEY = vm.envUint("DEPLOYER_PRIVATE_KEY"); 26 | address SIGNER_ADDRESS = vm.envAddress("SIGNER_ADDRESS"); 27 | address TREASURY_ADDRESS = vm.envAddress("TREASURY_ADDRESS"); 28 | address ATTESTER_ADDRESS = vm.envAddress("ATTESTER_ADDRESS"); 29 | 30 | function run() external { 31 | vm.startBroadcast(DEPLOYER_PRIVATE_KEY); 32 | 33 | // deploy EAS 34 | SchemaRegistry schemaRegistry = new SchemaRegistry(); 35 | EAS eas = new EAS(schemaRegistry); 36 | 37 | // deploy proxy admin 38 | ProxyAdmin proxyAdmin = new ProxyAdmin(); 39 | 40 | // deploy profile registry placeholder 41 | address placeholder = address(new EmptyContract()); 42 | address profileRegistryProxy = address(new TransparentUpgradeableProxy(placeholder, address(proxyAdmin), "")); 43 | 44 | // deploy Scroll badge resolver 45 | address resolverImpl = address(new ScrollBadgeResolver(address(eas), profileRegistryProxy)); 46 | address resolverProxy = address(new TransparentUpgradeableProxy(resolverImpl, address(proxyAdmin), "")); 47 | ScrollBadgeResolver resolver = ScrollBadgeResolver(payable(resolverProxy)); 48 | resolver.initialize(); 49 | 50 | bytes32 schema = resolver.schema(); 51 | 52 | // deploy profile implementation and upgrade registry 53 | Profile profileImpl = new Profile(address(resolver)); 54 | ProfileRegistry profileRegistryImpl = new ProfileRegistry(); 55 | proxyAdmin.upgrade(ITransparentUpgradeableProxy(profileRegistryProxy), address(profileRegistryImpl)); 56 | ProfileRegistry(profileRegistryProxy).initialize(TREASURY_ADDRESS, SIGNER_ADDRESS, address(profileImpl)); 57 | 58 | // deploy test badge 59 | ScrollBadgeSimple badge = new ScrollBadgeSimple(address(resolver), "uri"); 60 | AttesterProxy proxy = new AttesterProxy(eas); 61 | 62 | // set permissions 63 | resolver.toggleBadge(address(badge), true); 64 | badge.toggleAttester(address(proxy), true); 65 | proxy.toggleAttester(ATTESTER_ADDRESS, true); 66 | 67 | // log addresses 68 | logAddress("EAS_REGISTRY_CONTRACT_ADDRESS", address(schemaRegistry)); 69 | logAddress("EAS_MAIN_CONTRACT_ADDRESS", address(eas)); 70 | logAddress("SCROLL_BADGE_PROXY_ADMIN_ADDRESS", address(proxyAdmin)); 71 | logAddress("SCROLL_BADGE_RESOLVER_CONTRACT_ADDRESS", address(resolver)); 72 | logBytes32("SCROLL_BADGE_SCHEMA_UID", schema); 73 | logAddress("SIMPLE_BADGE_CONTRACT_ADDRESS", address(badge)); 74 | logAddress("SIMPLE_BADGE_ATTESTER_PROXY_CONTRACT_ADDRESS", address(proxy)); 75 | logAddress("SCROLL_PROFILE_IMPLEMENTATION_CONTRACT_ADDRESS", address(profileImpl)); 76 | logAddress("SCROLL_PROFILE_REGISTRY_IMPLEMENTATION_CONTRACT_ADDRESS", address(profileRegistryImpl)); 77 | logAddress("SCROLL_PROFILE_REGISTRY_PROXY_CONTRACT_ADDRESS", address(profileRegistryProxy)); 78 | 79 | vm.stopBroadcast(); 80 | } 81 | 82 | function logAddress(string memory name, address addr) internal view { 83 | console.log(string(abi.encodePacked(name, "=", vm.toString(address(addr))))); 84 | } 85 | 86 | function logBytes32(string memory name, bytes32 data) internal view { 87 | console.log(string(abi.encodePacked(name, "=", vm.toString(data)))); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /script/DeployCanvasTestBadgeContracts.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.19; 3 | 4 | import {Script} from "forge-std/Script.sol"; 5 | import {console} from "forge-std/console.sol"; 6 | 7 | import {Attestation, IEAS} from "@eas/contracts/IEAS.sol"; 8 | 9 | import {AttesterProxy} from "../src/AttesterProxy.sol"; 10 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 11 | import {EthereumYearBadge} from "../src/badge/examples/EthereumYearBadge.sol"; 12 | import {ScrollBadgeTokenOwner} from "../src/badge/examples/ScrollBadgeTokenOwner.sol"; 13 | import {ScrollBadgeSelfAttest} from "../src/badge/extensions/ScrollBadgeSelfAttest.sol"; 14 | import {ScrollBadgeSingleton} from "../src/badge/extensions/ScrollBadgeSingleton.sol"; 15 | import {ScrollBadgeResolver} from "../src/resolver/ScrollBadgeResolver.sol"; 16 | 17 | contract CanvasTestBadge is ScrollBadgeSelfAttest, ScrollBadgeSingleton { 18 | string public sharedTokenURI; 19 | 20 | constructor(address resolver_, string memory tokenUri_) ScrollBadge(resolver_) { 21 | sharedTokenURI = tokenUri_; 22 | } 23 | 24 | function onIssueBadge(Attestation calldata attestation) 25 | internal 26 | virtual 27 | override (ScrollBadgeSelfAttest, ScrollBadgeSingleton) 28 | returns (bool) 29 | { 30 | return super.onIssueBadge(attestation); 31 | } 32 | 33 | function onRevokeBadge(Attestation calldata attestation) 34 | internal 35 | virtual 36 | override (ScrollBadgeSelfAttest, ScrollBadgeSingleton) 37 | returns (bool) 38 | { 39 | return super.onRevokeBadge(attestation); 40 | } 41 | 42 | function badgeTokenURI(bytes32 /*uid*/ ) public view override returns (string memory) { 43 | return sharedTokenURI; 44 | } 45 | } 46 | 47 | contract DeployCanvasTestBadgeContracts is Script { 48 | uint256 DEPLOYER_PRIVATE_KEY = vm.envUint("DEPLOYER_PRIVATE_KEY"); 49 | 50 | address RESOLVER_ADDRESS = vm.envAddress("SCROLL_BADGE_RESOLVER_CONTRACT_ADDRESS"); 51 | address ETHEREUM_YEAR_SIGNER_ADDRESS = vm.envAddress("ETHEREUM_YEAR_SIGNER_ADDRESS"); 52 | 53 | address EAS_ADDRESS = vm.envAddress("EAS_ADDRESS"); 54 | 55 | bool IS_MAINNET = vm.envBool("IS_MAINNET"); 56 | 57 | function run() external { 58 | vm.startBroadcast(DEPLOYER_PRIVATE_KEY); 59 | 60 | ScrollBadgeResolver resolver = ScrollBadgeResolver(payable(RESOLVER_ADDRESS)); 61 | 62 | // deploy test badges 63 | CanvasTestBadge badge1 = new CanvasTestBadge( 64 | address(resolver), "ipfs://bafybeibc5sgo2plmjkq2tzmhrn54bk3crhnc23zd2msg4ea7a4pxrkgfna/1" 65 | ); 66 | 67 | CanvasTestBadge badge2 = new CanvasTestBadge( 68 | address(resolver), "ipfs://bafybeibc5sgo2plmjkq2tzmhrn54bk3crhnc23zd2msg4ea7a4pxrkgfna/2" 69 | ); 70 | 71 | CanvasTestBadge badge3 = new CanvasTestBadge( 72 | address(resolver), "ipfs://bafybeibc5sgo2plmjkq2tzmhrn54bk3crhnc23zd2msg4ea7a4pxrkgfna/3" 73 | ); 74 | 75 | // deploy origins NFT badge 76 | address[] memory tokens; 77 | 78 | if (IS_MAINNET) { 79 | tokens = new address[](2); 80 | tokens[0] = 0x74670A3998d9d6622E32D0847fF5977c37E0eC91; 81 | tokens[1] = 0x42bCaCb8D24Ba588cab8Db0BB737DD2eFca408EC; 82 | } else { 83 | tokens = new address[](1); 84 | tokens[0] = 0xDd7d857F570B0C211abfe05cd914A85BefEC2464; 85 | } 86 | 87 | ScrollBadgeTokenOwner badge4 = new ScrollBadgeTokenOwner(address(resolver), "", tokens); 88 | 89 | // deploy Ethereum year badge 90 | EthereumYearBadge badge5 = new EthereumYearBadge(address(resolver), "https://nft.scroll.io/canvas/year/"); 91 | AttesterProxy yearBadgeProxy = new AttesterProxy(IEAS(EAS_ADDRESS)); 92 | 93 | // set permissions 94 | badge5.toggleAttester(address(yearBadgeProxy), true); 95 | yearBadgeProxy.toggleAttester(ETHEREUM_YEAR_SIGNER_ADDRESS, true); 96 | 97 | // set permissions 98 | resolver.toggleBadge(address(badge1), true); 99 | resolver.toggleBadge(address(badge2), true); 100 | resolver.toggleBadge(address(badge3), true); 101 | resolver.toggleBadge(address(badge4), true); 102 | resolver.toggleBadge(address(badge5), true); 103 | 104 | // log addresses 105 | logAddress("DEPLOYER_ADDRESS", vm.addr(DEPLOYER_PRIVATE_KEY)); 106 | logAddress("SIMPLE_BADGE_A_CONTRACT_ADDRESS", address(badge1)); 107 | logAddress("SIMPLE_BADGE_B_CONTRACT_ADDRESS", address(badge2)); 108 | logAddress("SIMPLE_BADGE_C_CONTRACT_ADDRESS", address(badge3)); 109 | logAddress("ORIGINS_BADGE_ADDRESS", address(badge4)); 110 | logAddress("ETHEREUM_YEAR_BADGE_ADDRESS", address(badge5)); 111 | logAddress("ETHEREUM_YEAR_ATTESTER_PROXY_ADDRESS", address(yearBadgeProxy)); 112 | 113 | vm.stopBroadcast(); 114 | } 115 | 116 | function logAddress(string memory name, address addr) internal view { 117 | console.log(string(abi.encodePacked(name, "=", vm.toString(address(addr))))); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/badge/examples/ScrollBadgePowerRank.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | 7 | import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; 8 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 9 | 10 | import {ScrollBadge} from "../ScrollBadge.sol"; 11 | import {ScrollBadgeAccessControl} from "../extensions/ScrollBadgeAccessControl.sol"; 12 | import {ScrollBadgeCustomPayload} from "../extensions/ScrollBadgeCustomPayload.sol"; 13 | import {ScrollBadgeNoExpiry} from "../extensions/ScrollBadgeNoExpiry.sol"; 14 | import {ScrollBadgeNonRevocable} from "../extensions/ScrollBadgeNonRevocable.sol"; 15 | import {ScrollBadgeSingleton} from "../extensions/ScrollBadgeSingleton.sol"; 16 | import {IScrollBadgeUpgradeable} from "../extensions/IScrollBadgeUpgradeable.sol"; 17 | import {Unauthorized, CannotUpgrade} from "../../Errors.sol"; 18 | 19 | string constant SCROLL_BADGE_POWER_RANK_SCHEMA = "uint256 firstTxTimestamp"; 20 | 21 | function decodePayloadData(bytes memory data) pure returns (uint256) { 22 | return abi.decode(data, (uint256)); 23 | } 24 | 25 | /// @title ScrollBadgePowerRank 26 | /// @notice A badge that represents the user's power rank. 27 | contract ScrollBadgePowerRank is 28 | ScrollBadgeAccessControl, 29 | ScrollBadgeCustomPayload, 30 | ScrollBadgeNoExpiry, 31 | ScrollBadgeNonRevocable, 32 | ScrollBadgeSingleton, 33 | IScrollBadgeUpgradeable 34 | { 35 | event Upgrade(uint256 oldRank, uint256 newRank); 36 | 37 | // badge UID => current rank 38 | mapping(bytes32 => uint256) public badgeRank; 39 | 40 | constructor(address resolver_) ScrollBadge(resolver_) { 41 | // empty 42 | } 43 | 44 | /// @inheritdoc ScrollBadge 45 | function onIssueBadge(Attestation calldata attestation) 46 | internal 47 | override ( 48 | ScrollBadgeAccessControl, 49 | ScrollBadgeCustomPayload, 50 | ScrollBadgeNoExpiry, 51 | ScrollBadgeNonRevocable, 52 | ScrollBadgeSingleton 53 | ) 54 | returns (bool) 55 | { 56 | if (!super.onIssueBadge(attestation)) { 57 | return false; 58 | } 59 | 60 | bytes memory payload = getPayload(attestation); 61 | (uint256 firstTxTimestamp) = decodePayloadData(payload); 62 | badgeRank[attestation.uid] = timestampToRank(firstTxTimestamp); 63 | 64 | return true; 65 | } 66 | 67 | /// @inheritdoc ScrollBadge 68 | function onRevokeBadge(Attestation calldata attestation) 69 | internal 70 | override ( 71 | ScrollBadge, ScrollBadgeAccessControl, ScrollBadgeCustomPayload, ScrollBadgeNoExpiry, ScrollBadgeSingleton 72 | ) 73 | returns (bool) 74 | { 75 | return super.onRevokeBadge(attestation); 76 | } 77 | 78 | /// @inheritdoc ScrollBadge 79 | function badgeTokenURI(bytes32 uid) public view override returns (string memory) { 80 | uint256 rank = badgeRank[uid]; 81 | string memory name = string(abi.encodePacked("Scroll Power Rank #", Strings.toString(rank))); 82 | string memory description = "Scroll Power Rank Badge"; 83 | string memory image = ""; // IPFS, HTTP, or data URL 84 | string memory tokenUriJson = Base64.encode( 85 | abi.encodePacked('{"name":"', name, '", "description":"', description, '", "image": "', image, '"}') 86 | ); 87 | return string(abi.encodePacked("data:application/json;base64,", tokenUriJson)); 88 | } 89 | 90 | /// @inheritdoc ScrollBadgeCustomPayload 91 | function getSchema() public pure override returns (string memory) { 92 | return SCROLL_BADGE_POWER_RANK_SCHEMA; 93 | } 94 | 95 | /// @inheritdoc IScrollBadgeUpgradeable 96 | function canUpgrade(bytes32 uid) external view returns (bool) { 97 | Attestation memory badge = getAndValidateBadge(uid); 98 | 99 | bytes memory payload = getPayload(badge); 100 | (uint256 firstTxTimestamp) = decodePayloadData(payload); 101 | uint256 newRank = timestampToRank(firstTxTimestamp); 102 | 103 | uint256 oldRank = badgeRank[uid]; 104 | return newRank > oldRank; 105 | } 106 | 107 | /// @inheritdoc IScrollBadgeUpgradeable 108 | function upgrade(bytes32 uid) external { 109 | Attestation memory badge = getAndValidateBadge(uid); 110 | 111 | if (msg.sender != badge.recipient) { 112 | revert Unauthorized(); 113 | } 114 | 115 | bytes memory payload = getPayload(badge); 116 | (uint256 firstTxTimestamp) = decodePayloadData(payload); 117 | uint256 newRank = timestampToRank(firstTxTimestamp); 118 | 119 | uint256 oldRank = badgeRank[uid]; 120 | if (newRank <= oldRank) { 121 | revert CannotUpgrade(uid); 122 | } 123 | 124 | badgeRank[uid] = newRank; 125 | emit Upgrade(oldRank, newRank); 126 | } 127 | 128 | function timestampToRank(uint256 timestamp) public view returns (uint256) { 129 | return (block.timestamp - timestamp) / 2_592_000 + 1; // level up every 30 days 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Scroll Canvas Developer Documentation 2 | 3 | ![Components overview](../images/overview.png "Overview") 4 | 5 | ([Editable link](https://viewer.diagrams.net/?tags=%7B%7D&highlight=0000ff&edit=_blank&layers=1&nav=1&title=skelly-v4.drawio#R7VpLc6M4EP41rpo5xIWEMeYYx8nsIdma2Rx2clRABs0K5BVybO%2BvXwkknk7ijCEwNalUyqjVenV%2F%2FVDDxL6K91842kR3LMB0Aq1gP7FXEwgBsIH8UZRDTllY85wQchJoppJwT%2F7Dmmhp6pYEOK0xCsaoIJs60WdJgn1RoyHO2a7Otma0vuoGhbhFuPcRbVP%2FJoGI9Ckcq6T%2FgUkYmZWBpXtiZJg1IY1QwHYVkn09sa84YyJ%2FivdXmCrhGbnk426e6S02xnEiThkQf%2Ft2uwvggibu7Q9%2Fk1hfCbzQykjFwRwYB%2FL8usm4iFjIEkSvS%2BqSs20SYDWrJVslzy1jG0kEkvgDC3HQykRbwSQpEjHVvXLD%2FPBdj88aD6oxdUxzta92rg66lQrO%2FinUIAW4XBNKrxhlPNu7vV742PcLzkrP48KZOWqOttS0IFO25T5%2BQVQGfYiHWLzAZ%2Bd8So6VBbROvmAWY3kmycAxRYI81XGGNFzDgq%2FUqHzQSn2DgsEsn%2FgJ0a1eagLnVB5gGZCnmurn%2F24VFpd%2BLrZLtcfw8ZPjTaBc2yp%2FP2dylLaUiIs1igk95Nx3OKEsZ7qTgPD1s5wZxRIZSz3%2FlZQ0wVz2%2FIl3zc58SMwSlm6Q0kexUprBSa0DrM0%2B76AkwReRtr%2Bsy9Fd82K%2Beah%2BkRA4FZ8%2Bm8NLYWbnz3uPWsEtepTOrIZcREmYyGdfokeewF4%2BYS6I9BaXuiMmQZAbCZb7RY%2FZfAp3G0YSkSnXWU6clcKuPJQxNKsNy5fMVa2K95Mj%2Fk%2BvWHMxNdTpURfW1Fo4bj72UJvpZFzqyb%2Bqk5WzXHj1EWy9TqW9NHFcbOkMaLeQfX1539LkLiIC3%2BdYWu1kkGpoNN3kYWNN9sqn1dRyxMeCZzTwrKTthVWTsu3q9q4STjQpqkQSQ%2BvcI8CW2O79CMfIWMYjN1bxFw5JapY8R6Ytd7zM%2FrS0j9F7EDx0Bha8O2isLRqnxVq8J%2BJ7ySlbD5WecpBq1OPzm9T5atS1T4y6cFRR12uperCkqR5mOrXFzpQ3G5XyzNVjDNobNuU9VX%2FOqPRnHwlwnFG6RGp%2FR6JcyuiTykdHljk4zsgyh%2FZVoibYDsXXsaM6XeZuU%2BZwYJk77ybzLiDbFN9saPEB%2BJv48ld9NOjaRzeufxoB0G0gADRUmwcdPaqHS2Hb%2B3%2FUO37Zeocx304KHrAGTN06E%2B5FsmiqH4v6DD1WPxYfQFe%2F1pRnZQrMxwT202Ppm5Hs2I26Rn%2BVonbForfgCU8tSIBqOaIoTrxfQQLOhwq4512KwFEz6L36FFyql3CymbAE55QborauFYS4MBw%2BRWlKfEPWbKBnPfadEIFZw1y9hrnmQGolRFIq6FBh0z7mzevc%2FBy%2FfMh30GnQMkKvBK3LLFFRUaN5Mf%2FK2f782vMorpWwKeyha9HwI3vIMEbSdIuzu%2FSYsodXUuXOUom5Z00tS6JzMfechV2%2FuM1U38yZyX%2FHdW3YV2CyPpBYXtiWh7YfXGGKQxmCWDImjPYIS5DBEngAQMe1JDgXdec596ae58zmnu1CD3p9AXPgb2PKetJDte%2B59PiVTCtAaZTtC5yVNRk38Ktlv27LyUh7Q340Dst5EX%2Fn25M1BZb51uNns9n%2BCxoGWhUdyQxwTej4ytzNTwtm3sDpnH3sK68hndfkTZXxM%2FzRqa8oDbpedUhlvdC27XpKZGp9I7ah9usin2Mk2iY0Ij9nsNtFjRdA68zCrinkNt9b9Ki09iuLwvH19klW1y4RgndzibJZfjWdK6H89ty%2B%2Fh8%3D)) 6 | 7 | 8 | # Overview 9 | 10 | Scroll Canvas consists of the following components: 11 | - [**ProfileRegistry**](../src/profile/ProfileRegistry.sol): A contract for users to mint and query their Canvases. 12 | - [**Profile**](../src/profile/Profile.sol): Each Canvas is an instance of the profile smart contract. 13 | - [**EAS**](https://docs.attest.org/docs/welcome): A technology for issuing on-chain attestations. 14 | - [**ScrollBadgeResolver**](../src/resolver/ScrollBadgeResolver.sol): Each attestation passes through this resolver before the badge is minted. It enforces Canvas badge rules. 15 | - [**ScrollBadge**](../src/badge/ScrollBadge.sol): Each badge is a contract the conforms to a certain [interface](../src/interfaces/IScrollBadge.sol). 16 | 17 | 18 | ## Profiles 19 | 20 | Each user can mint a [`Profile`](../src/profile/Profile.sol) instance through [`ProfileRegistry`](../src/profile/ProfileRegistry.sol). 21 | This contract is the user's Canvas, and minting it is a prerequisite to collecting badges. 22 | Each wallet can only mint one profile. 23 | All profiles share the same implementation, upgradable by Scroll to enable new features. 24 | 25 | The main use of profiles is personalization. 26 | Users can configure a username and an avatar. 27 | Users can also decide which badges they attach to their profile, and in which order they want to display them. 28 | 29 | See the [Canvas Interaction Guide](./canvas-interaction-guide.md) section for more details. 30 | 31 | 32 | ## ScrollBadge Schema and Resolver 33 | 34 | We define a *Scroll badge* [EAS schema](https://docs.attest.org/docs/core--concepts/schemas): 35 | 36 | ``` 37 | address badge 38 | bytes payload 39 | ``` 40 | 41 | This schema is tied to `ScrollBadgeResolver`. 42 | Every time a Scroll badge attestation is created or revoked through EAS, `ScrollBadgeResolver` executes some checks and actions. 43 | After this, it forwards the call to the actual badge implementation. 44 | 45 | You can find the schema UID in the [Deployments](./deployments.md) section. 46 | Browse the Scroll mainnet badge attestations on the [EAS Explorer](https://scroll.easscan.org/schema/view/0xd57de4f41c3d3cc855eadef68f98c0d4edd22d57161d96b7c06d2f4336cc3b49). 47 | 48 | 49 | ## Badges 50 | 51 | Each badge is an [EAS attestation](https://docs.attest.org/docs/core--concepts/attestations) that goes through the [`ScrollBadgeResolver`](../src/resolver/ScrollBadgeResolver.sol) contract and a badge contract. 52 | 53 | Each badge type is a standalone contract that inherits from [`ScrollBadge`](../src/badge/ScrollBadge.sol). 54 | This badge contract can implement arbitrary logic attached to the attestation. 55 | Badges implement a `badgeTokenURI` interface, similar to `ERC721.tokenURI`. 56 | 57 | Badges are minted to the user's wallet address. 58 | The user can express their personalization preferences (attach and reorder badges, choose a profile photo) through their Canvas [`Profile`](../src/profile/Profile.sol). 59 | 60 | See the [Badges](./badges.md) section for more details, and [Badge Examples](./badge-examples.md) for Solidity code examples. 61 | 62 | 63 | ## Explore the Documentation 64 | 65 | Explore the following pages to learn more about different aspects of Canvas: 66 | - [Deployments](./deployments.md) lists the official Canvas contract addresses on Scroll mainnet and on the Scroll Sepolia testnet. 67 | - [Badges](./badges.md) introduces the basic requirements for badge contracts and lists resources for getting started as a badge developer. 68 | - [Badge Examples](./badge-examples.md) shows the process of developing custom badges by going through some common examples and use cases. 69 | - [Canvas Interaction Guide](./canvas-interaction-guide.md) lists common questions and examples for interacting with Canvas profiles and badges. 70 | - [Official Badges](./official-badges) contains addresses and documentation for some badges issued by Scroll. 71 | -------------------------------------------------------------------------------- /script/CheckBadge.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity 0.8.19; 3 | 4 | import {Script} from "forge-std/Script.sol"; 5 | import {console} from "forge-std/console.sol"; 6 | 7 | import {AttesterProxy} from "../src/AttesterProxy.sol"; 8 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 9 | import {ScrollBadgeEligibilityCheck} from "../src/badge/extensions/ScrollBadgeEligibilityCheck.sol"; 10 | import {ScrollBadgeAccessControl} from "../src/badge/extensions/ScrollBadgeAccessControl.sol"; 11 | 12 | contract CheckBadge is Script { 13 | uint256 SCROLL_CHAIN_ID = 534_352; 14 | address SCROLL_BADGE_RESOLVER_CONTRACT_ADDRESS = 0x4560FECd62B14A463bE44D40fE5Cfd595eEc0113; 15 | 16 | function run() external { 17 | address badge = vm.promptAddress("Please provide your badge address"); 18 | address attesterProxy = promptAddressOpt("Please provide your attester proxy address (leave empty if none)"); 19 | address signer = promptAddressOpt("Please provide your backend signer address (leave empty if none)"); 20 | 21 | run(badge, attesterProxy, signer); 22 | } 23 | 24 | function run(address badge, address attesterProxy, address signer) public { 25 | console.log( 26 | string( 27 | abi.encodePacked( 28 | "Checking badge ", 29 | vm.toString(badge), 30 | " with attester proxy ", 31 | vm.toString(attesterProxy), 32 | " and signer ", 33 | vm.toString(signer) 34 | ) 35 | ) 36 | ); 37 | 38 | // check chain id 39 | if (block.chainid != SCROLL_CHAIN_ID) { 40 | revert("Wrong chain, make sure to run this script with --rpc-url https://rpc.scroll.io"); 41 | } 42 | 43 | // check if badge exists 44 | if (badge.code.length == 0) { 45 | revert(unicode"❌ Badge contract not deployed"); 46 | } else { 47 | console.log(unicode"✅ Badge contract deployed"); 48 | } 49 | 50 | // check if attester proxy exists 51 | if (attesterProxy != address(0) && attesterProxy.code.length == 0) { 52 | revert(unicode"❌ Attester proxy contract not deployed"); 53 | } else { 54 | console.log(unicode"✅ Attester proxy contract deployed"); 55 | } 56 | 57 | // check resolver 58 | try ScrollBadge(badge).resolver() returns (address resolver) { 59 | if (resolver != SCROLL_BADGE_RESOLVER_CONTRACT_ADDRESS) { 60 | console.log( 61 | unicode"❌ Incorrect resolver, make sure that you pass the correct constructor argument to ScrollBadge" 62 | ); 63 | } else { 64 | console.log(unicode"✅ Badge resolver configured"); 65 | } 66 | } catch { 67 | console.log(unicode"❌ Failed to call badge.resolver(), make sure that your badge implements ScrollBadge"); 68 | } 69 | 70 | // check default badgeTokenURI 71 | try ScrollBadge(badge).badgeTokenURI(bytes32("")) returns (string memory defaultUri) { 72 | if (bytes(defaultUri).length == 0) { 73 | console.log( 74 | unicode"❌ Missing default badge URI, make sure that your badge implements ScrollBadgeDefaultURI" 75 | ); 76 | } else { 77 | console.log(unicode"✅ Default badge URI is configured"); 78 | } 79 | } catch { 80 | console.log( 81 | unicode"❌ Missing default badge URI, make sure that your badge implements ScrollBadgeDefaultURI" 82 | ); 83 | } 84 | 85 | // on-chain eligibility check 86 | if (attesterProxy == address(0)) { 87 | try ScrollBadgeEligibilityCheck(badge).isEligible(address(1)) { 88 | console.log(unicode"✅ On-chain eligibility check is configured"); 89 | } catch { 90 | console.log( 91 | unicode"❌ Missing on-chain eligibility check, make sure that your badge implements ScrollBadgeEligibilityCheck" 92 | ); 93 | } 94 | } 95 | 96 | // authorization 97 | if (attesterProxy != address(0)) { 98 | try ScrollBadgeAccessControl(badge).isAttester(attesterProxy) returns (bool isAttester) { 99 | if (!isAttester) { 100 | console.log( 101 | unicode"❌ Attester proxy is not whitelisted, please call badge.toggleAttester(attesterProxy, true)" 102 | ); 103 | } else { 104 | console.log(unicode"✅ Attester proxy is whitelisted"); 105 | } 106 | } catch { 107 | console.log( 108 | unicode"❌ Missing access control, make sure that your badge implements ScrollBadgeAccessControl" 109 | ); 110 | } 111 | } 112 | 113 | if (attesterProxy != address(0) && signer != address(0)) { 114 | try AttesterProxy(attesterProxy).isAttester(signer) returns (bool isAttester) { 115 | if (!isAttester) { 116 | console.log( 117 | unicode"❌ Your signer is not whitelisted, please call attesterProxy.toggleAttester(signer, true)" 118 | ); 119 | } else { 120 | console.log(unicode"✅ Signer is whitelisted"); 121 | } 122 | } catch { 123 | console.log( 124 | unicode"❌ Failed to query attester proxy, make sure this contract is an instance of AttesterProxy" 125 | ); 126 | } 127 | } 128 | } 129 | 130 | function promptAddressOpt(string memory promptText) private returns (address addr) { 131 | string memory str = vm.prompt(promptText); 132 | 133 | if (bytes(str).length > 0) { 134 | addr = vm.parseAddress(str); 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/badge/examples/SCRHoldingBadge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation} from "@eas/contracts/IEAS.sol"; 6 | import {NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 7 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 8 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 9 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 10 | 11 | import {IScrollBadgeResolver} from "../../interfaces/IScrollBadgeResolver.sol"; 12 | import {IScrollBadge, IScrollSelfAttestationBadge} from "../../interfaces/IScrollSelfAttestationBadge.sol"; 13 | import {encodeBadgeData} from "../../Common.sol"; 14 | import {ScrollBadge} from "../ScrollBadge.sol"; 15 | import {ScrollBadgeCustomPayload} from "../extensions/ScrollBadgeCustomPayload.sol"; 16 | import {ScrollBadgeDefaultURI} from "../extensions/ScrollBadgeDefaultURI.sol"; 17 | 18 | string constant SCR_HOLDING_BADGE_SCHEMA = "uint256 level"; 19 | 20 | function decodePayloadData(bytes memory data) pure returns (uint256) { 21 | return abi.decode(data, (uint256)); 22 | } 23 | 24 | /// @title SCRHoldingBadge 25 | /// @notice A badge that represents user's SCR holding amount. 26 | contract SCRHoldingBadge is ScrollBadgeCustomPayload, ScrollBadgeDefaultURI, Ownable, IScrollSelfAttestationBadge { 27 | uint256 private constant LEVEL_ONE_SCR_AMOUNT = 1 ether; 28 | uint256 private constant LEVEL_TWO_SCR_AMOUNT = 10 ether; 29 | uint256 private constant LEVEL_THREE_SCR_AMOUNT = 100 ether; 30 | uint256 private constant LEVEL_FOUR_SCR_AMOUNT = 1000 ether; 31 | uint256 private constant LEVEL_FIVE_SCR_AMOUNT = 10_000 ether; 32 | uint256 private constant LEVEL_SIX_SCR_AMOUNT = 100_000 ether; 33 | 34 | /// @notice The address of SCR token. 35 | address public immutable scr; 36 | 37 | constructor(address resolver_, string memory baseTokenURI_, address scr_) 38 | ScrollBadge(resolver_) 39 | ScrollBadgeDefaultURI(baseTokenURI_) 40 | { 41 | scr = scr_; 42 | } 43 | 44 | /// @notice Update the base token URI. 45 | /// @param baseTokenURI_ The new base token URI. 46 | function updateBaseTokenURI(string memory baseTokenURI_) external onlyOwner { 47 | defaultBadgeURI = baseTokenURI_; 48 | } 49 | 50 | /// @inheritdoc ScrollBadge 51 | function onIssueBadge(Attestation calldata) 52 | internal 53 | virtual 54 | override (ScrollBadge, ScrollBadgeCustomPayload) 55 | returns (bool) 56 | { 57 | return false; 58 | } 59 | 60 | /// @inheritdoc ScrollBadge 61 | function onRevokeBadge(Attestation calldata) 62 | internal 63 | virtual 64 | override (ScrollBadge, ScrollBadgeCustomPayload) 65 | returns (bool) 66 | { 67 | return false; 68 | } 69 | 70 | /// @inheritdoc ScrollBadge 71 | function badgeTokenURI(bytes32 uid) 72 | public 73 | view 74 | override (IScrollBadge, ScrollBadge, ScrollBadgeDefaultURI) 75 | returns (string memory) 76 | { 77 | return ScrollBadgeDefaultURI.badgeTokenURI(uid); 78 | } 79 | 80 | /// @inheritdoc IScrollBadge 81 | function hasBadge(address user) public view virtual override (IScrollBadge, ScrollBadge) returns (bool) { 82 | uint256 balance = IERC20(scr).balanceOf(user); 83 | return balance >= LEVEL_ONE_SCR_AMOUNT; 84 | } 85 | 86 | /// @inheritdoc ScrollBadgeDefaultURI 87 | function getBadgeTokenURI(bytes32 uid) internal view override returns (string memory) { 88 | Attestation memory attestation = getAndValidateBadge(uid); 89 | bytes memory payload = getPayload(attestation); 90 | uint256 level = decodePayloadData(payload); 91 | 92 | return string(abi.encodePacked(defaultBadgeURI, Strings.toString(level), ".json")); 93 | } 94 | 95 | /// @inheritdoc ScrollBadgeCustomPayload 96 | function getSchema() public pure override returns (string memory) { 97 | return SCR_HOLDING_BADGE_SCHEMA; 98 | } 99 | 100 | /// @inheritdoc IScrollSelfAttestationBadge 101 | function getBadgeId() external pure returns (uint256) { 102 | return 0; 103 | } 104 | 105 | /// @inheritdoc IScrollSelfAttestationBadge 106 | /// 107 | /// @dev The uid encoding should be 108 | /// ```text 109 | /// [ address | badge id | customized data ] 110 | /// [ 160 bits | 32 bits | 64 bits ] 111 | /// [LSB MSB] 112 | /// ``` 113 | /// The *badge id* and the *customized data* should both be zero. 114 | function getAttestation(bytes32 uid) external view override returns (Attestation memory attestation) { 115 | // invalid uid, return empty badge 116 | if ((uint256(uid) >> 160) > 0) return attestation; 117 | 118 | // extract badge recipient from uid 119 | address recipient; 120 | assembly { 121 | recipient := and(uid, 0xffffffffffffffffffffffffffffffffffffffff) 122 | } 123 | 124 | // compute payload 125 | uint256 level; 126 | uint256 balance = IERC20(scr).balanceOf(recipient); 127 | // not hold enough SCR, return empty badge 128 | if (balance < LEVEL_ONE_SCR_AMOUNT) return attestation; 129 | else if (balance < LEVEL_TWO_SCR_AMOUNT) level = 1; 130 | else if (balance < LEVEL_THREE_SCR_AMOUNT) level = 2; 131 | else if (balance < LEVEL_FOUR_SCR_AMOUNT) level = 3; 132 | else if (balance < LEVEL_FIVE_SCR_AMOUNT) level = 4; 133 | else if (balance < LEVEL_SIX_SCR_AMOUNT) level = 5; 134 | else level = 6; 135 | bytes memory payload = abi.encode(level); 136 | 137 | // fill data in Attestation 138 | attestation.uid = uid; 139 | attestation.schema = IScrollBadgeResolver(resolver).schema(); 140 | attestation.time = uint64(block.timestamp); 141 | attestation.expirationTime = NO_EXPIRATION_TIME; 142 | attestation.refUID = bytes32(0); 143 | attestation.recipient = recipient; 144 | attestation.attester = address(this); 145 | attestation.revocable = false; 146 | attestation.data = encodeBadgeData(address(this), payload); 147 | 148 | return attestation; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/resolver/ScrollBadgeResolver.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Attestation, IEAS} from "@eas/contracts/IEAS.sol"; 6 | import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 7 | import {SchemaResolver, ISchemaResolver} from "@eas/contracts/resolver/SchemaResolver.sol"; 8 | 9 | import {Address} from "@openzeppelin/contracts/utils/Address.sol"; 10 | 11 | import {IProfile} from "../interfaces/IProfile.sol"; 12 | import {IProfileRegistry} from "../interfaces/IProfileRegistry.sol"; 13 | import {IScrollBadge} from "../interfaces/IScrollBadge.sol"; 14 | import {IScrollBadgeResolver} from "../interfaces/IScrollBadgeResolver.sol"; 15 | import {IScrollSelfAttestationBadge} from "../interfaces/IScrollSelfAttestationBadge.sol"; 16 | import {SCROLL_BADGE_SCHEMA, decodeBadgeData} from "../Common.sol"; 17 | import {ScrollBadgeResolverWhitelist} from "./ScrollBadgeResolverWhitelist.sol"; 18 | 19 | import { 20 | AttestationExpired, 21 | AttestationNotFound, 22 | AttestationRevoked, 23 | AttestationSchemaMismatch, 24 | BadgeNotAllowed, 25 | BadgeNotFound, 26 | ResolverPaymentsDisabled, 27 | UnknownSchema 28 | } from "../Errors.sol"; 29 | 30 | /// @title ScrollBadgeResolver 31 | /// @notice This resolver contract receives callbacks every time a Scroll badge 32 | // attestation is created or revoked. It executes some basic checks and 33 | // then delegates the logic to the specific badge implementation. 34 | contract ScrollBadgeResolver is IScrollBadgeResolver, SchemaResolver, ScrollBadgeResolverWhitelist { 35 | /** 36 | * 37 | * Constants * 38 | * 39 | */ 40 | 41 | /// @inheritdoc IScrollBadgeResolver 42 | address public immutable registry; 43 | 44 | /** 45 | * 46 | * Variables * 47 | * 48 | */ 49 | 50 | /// @inheritdoc IScrollBadgeResolver 51 | bytes32 public schema; 52 | 53 | /// @notice The list of self attested badges, mapping from badge id to badge address. 54 | /// @dev This is a list of badges with special needs which EAS cannot satisfy, such as 55 | /// auto attest/revoke badge based on certain token holding amount. 56 | /// The uid for the badge is customized in the following way: 57 | /// ```text 58 | /// [ address | badge id | customized data ] 59 | /// [ 160 bits | 32 bits | 64 bits ] 60 | /// [LSB MSB] 61 | /// ``` 62 | mapping(uint256 => address) public selfAttestedBadges; 63 | 64 | // Storage slots reserved for future upgrades. 65 | uint256[48] private __gap; 66 | 67 | /** 68 | * 69 | * Constructor * 70 | * 71 | */ 72 | 73 | /// @dev Creates a new ScrollBadgeResolver instance. 74 | /// @param eas_ The address of the global EAS contract. 75 | /// @param registry_ The address of the profile registry contract. 76 | constructor(address eas_, address registry_) SchemaResolver(IEAS(eas_)) { 77 | registry = registry_; 78 | _disableInitializers(); 79 | } 80 | 81 | function initialize() external initializer { 82 | __Whitelist_init(); 83 | 84 | // register Scroll badge schema, 85 | // we do this here to ensure that the resolver is correctly configured 86 | schema = _eas.getSchemaRegistry().register( 87 | SCROLL_BADGE_SCHEMA, 88 | ISchemaResolver(address(this)), // resolver 89 | true // revocable 90 | ); 91 | } 92 | 93 | /** 94 | * 95 | * Schema Resolver Functions * 96 | * 97 | */ 98 | 99 | /// @inheritdoc SchemaResolver 100 | function onAttest(Attestation calldata attestation, uint256 value) 101 | internal 102 | override (SchemaResolver) 103 | returns (bool) 104 | { 105 | // do not accept resolver tips 106 | if (value != 0) { 107 | revert ResolverPaymentsDisabled(); 108 | } 109 | 110 | // do not process other schemas 111 | if (attestation.schema != schema) { 112 | revert UnknownSchema(); 113 | } 114 | 115 | // decode attestation 116 | (address badge,) = decodeBadgeData(attestation.data); 117 | 118 | // check if badge exists 119 | if (!Address.isContract(badge)) { 120 | revert BadgeNotFound(badge); 121 | } 122 | 123 | // check badge whitelist 124 | if (whitelistEnabled && !whitelist[badge]) { 125 | revert BadgeNotAllowed(badge); 126 | } 127 | 128 | // delegate to badge contract for application-specific checks and actions 129 | if (!IScrollBadge(badge).issueBadge(attestation)) { 130 | return false; 131 | } 132 | 133 | // auto-attach self-minted badges 134 | // note: in some cases attestation.attester is a proxy, so we also check tx.origin. 135 | if (attestation.recipient == attestation.attester || attestation.recipient == tx.origin) { 136 | _autoAttach(attestation); 137 | } 138 | 139 | emit IssueBadge(attestation.uid); 140 | return true; 141 | } 142 | 143 | /// @inheritdoc SchemaResolver 144 | function onRevoke(Attestation calldata attestation, uint256 value) 145 | internal 146 | override (SchemaResolver) 147 | returns (bool) 148 | { 149 | // do not accept resolver tips 150 | if (value != 0) { 151 | revert ResolverPaymentsDisabled(); 152 | } 153 | 154 | // delegate to badge contract for application-specific checks and actions 155 | (address badge,) = decodeBadgeData(attestation.data); 156 | 157 | if (!IScrollBadge(badge).revokeBadge(attestation)) { 158 | return false; 159 | } 160 | 161 | emit RevokeBadge(attestation.uid); 162 | return true; 163 | } 164 | 165 | /** 166 | * 167 | * Public View Functions * 168 | * 169 | */ 170 | 171 | /// @inheritdoc IScrollBadgeResolver 172 | function eas() external view returns (address) { 173 | return address(_eas); 174 | } 175 | 176 | /// @inheritdoc IScrollBadgeResolver 177 | function getAndValidateBadge(bytes32 uid) external view returns (Attestation memory) { 178 | Attestation memory attestation = _eas.getAttestation(uid); 179 | 180 | // if we cannot find the badge in EAS, try self attestation 181 | if (attestation.uid == EMPTY_UID) { 182 | // extract badge address from uid and do self attestation 183 | uint256 badgeId = uint256(uid) >> 160 & 0xffffffff; 184 | address badgeAddr = selfAttestedBadges[badgeId]; 185 | if (badgeAddr != address(0)) { 186 | attestation = IScrollSelfAttestationBadge(badgeAddr).getAttestation(uid); 187 | } 188 | if (attestation.uid == EMPTY_UID) { 189 | revert AttestationNotFound(uid); 190 | } else { 191 | return attestation; 192 | } 193 | } 194 | 195 | if (attestation.schema != schema) { 196 | revert AttestationSchemaMismatch(uid); 197 | } 198 | 199 | if (attestation.expirationTime != NO_EXPIRATION_TIME && attestation.expirationTime <= block.timestamp) { 200 | revert AttestationExpired(uid); 201 | } 202 | 203 | if (attestation.revocationTime != 0 && attestation.revocationTime <= block.timestamp) { 204 | revert AttestationRevoked(uid); 205 | } 206 | 207 | return attestation; 208 | } 209 | 210 | /** 211 | * 212 | * Restricted Functions * 213 | * 214 | */ 215 | 216 | /// @notice Update the address of a self attested badge. 217 | function updateSelfAttestedBadge(uint256 badgeId, address badgeAddress) external onlyOwner { 218 | selfAttestedBadges[badgeId] = badgeAddress; 219 | } 220 | 221 | /** 222 | * 223 | * Internal Functions * 224 | * 225 | */ 226 | function _autoAttach(Attestation calldata attestation) internal { 227 | IProfileRegistry _registry = IProfileRegistry(registry); 228 | address profile = _registry.getProfile(attestation.recipient); 229 | 230 | if (!_registry.isProfileMinted(profile)) { 231 | return; 232 | } 233 | 234 | // note: at this point the attestation is already registered in EAS, 235 | // so attaching it should succeed, unless the profile is full, in 236 | // which case the following call will be a no-op. 237 | IProfile(profile).autoAttach(attestation.uid); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /test/ScrollBadgeResolver.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 8 | import {EAS} from "@eas/contracts/EAS.sol"; 9 | import {ISchemaResolver} from "@eas/contracts/resolver/ISchemaResolver.sol"; 10 | 11 | import { 12 | Attestation, 13 | AttestationRequest, 14 | AttestationRequestData, 15 | RevocationRequest, 16 | RevocationRequestData 17 | } from "@eas/contracts/IEAS.sol"; 18 | 19 | import {IScrollBadge, ScrollBadge} from "../src/badge/ScrollBadge.sol"; 20 | 21 | import { 22 | AttestationExpired, 23 | AttestationNotFound, 24 | AttestationRevoked, 25 | AttestationSchemaMismatch, 26 | BadgeNotAllowed, 27 | BadgeNotFound, 28 | UnknownSchema 29 | } from "../src/Errors.sol"; 30 | 31 | contract TestContract is ScrollBadge { 32 | constructor(address resolver_) ScrollBadge(resolver_) {} 33 | 34 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 35 | return ""; 36 | } 37 | } 38 | 39 | contract ScrollBadgeTest is ScrollBadgeTestBase { 40 | ScrollBadge internal badge; 41 | 42 | function setUp() public virtual override { 43 | super.setUp(); 44 | 45 | badge = new TestContract(address(resolver)); 46 | resolver.toggleBadge(address(badge), true); 47 | } 48 | 49 | function testResolverToggleBadgeOnlyOwner(address notOwner, address anyBadge, bool enable) external { 50 | vm.assume(notOwner != address(this)); 51 | vm.assume(notOwner != PROXY_ADMIN_ADDRESS); 52 | vm.prank(notOwner); 53 | vm.expectRevert("Ownable: caller is not the owner"); 54 | resolver.toggleBadge(anyBadge, enable); 55 | } 56 | 57 | function testResolverToggleWhitelistOnlyOwner(address notOwner, bool enable) external { 58 | vm.assume(notOwner != address(this)); 59 | vm.assume(notOwner != PROXY_ADMIN_ADDRESS); 60 | vm.prank(notOwner); 61 | vm.expectRevert("Ownable: caller is not the owner"); 62 | resolver.toggleWhitelist(enable); 63 | } 64 | 65 | function testGetBadge() external { 66 | bytes32 uid = _attest(address(badge), "", alice); 67 | Attestation memory attestation = resolver.getAndValidateBadge(uid); 68 | assertEq(attestation.uid, uid); 69 | } 70 | 71 | function testGetNonExistentBadgeFails(bytes32 uid) external { 72 | vm.expectRevert(abi.encodeWithSelector(AttestationNotFound.selector, uid)); 73 | resolver.getAndValidateBadge(uid); 74 | } 75 | 76 | function testGetWrongSchemaBadgeFails() external { 77 | bytes32 otherSchema = registry.register("address badge", ISchemaResolver(address(0)), true); 78 | 79 | AttestationRequestData memory _attData = AttestationRequestData({ 80 | recipient: alice, 81 | expirationTime: NO_EXPIRATION_TIME, 82 | revocable: true, 83 | refUID: EMPTY_UID, 84 | data: abi.encode(badge, ""), 85 | value: 0 86 | }); 87 | 88 | AttestationRequest memory _req = AttestationRequest({schema: otherSchema, data: _attData}); 89 | bytes32 uid = eas.attest(_req); 90 | 91 | vm.expectRevert(abi.encodeWithSelector(AttestationSchemaMismatch.selector, uid)); 92 | resolver.getAndValidateBadge(uid); 93 | } 94 | 95 | function testGetExpiredBadgenFails() external { 96 | uint64 expirationTime = uint64(block.timestamp) + 1; 97 | 98 | AttestationRequestData memory _attData = AttestationRequestData({ 99 | recipient: alice, 100 | expirationTime: expirationTime, 101 | revocable: true, 102 | refUID: EMPTY_UID, 103 | data: abi.encode(badge, ""), 104 | value: 0 105 | }); 106 | 107 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 108 | bytes32 uid = eas.attest(_req); 109 | 110 | vm.warp(expirationTime); 111 | 112 | vm.expectRevert(abi.encodeWithSelector(AttestationExpired.selector, uid)); 113 | resolver.getAndValidateBadge(uid); 114 | } 115 | 116 | function testGetRevokedBadgeFails() external { 117 | bytes32 uid = _attest(address(badge), "", alice); 118 | _revoke(uid); 119 | 120 | vm.expectRevert(abi.encodeWithSelector(AttestationRevoked.selector, uid)); 121 | resolver.getAndValidateBadge(uid); 122 | } 123 | 124 | // test only EAS can call 125 | 126 | function testAttestRejectPayment(uint256 value) external { 127 | vm.assume(value > 0); 128 | vm.deal(address(this), value); 129 | 130 | AttestationRequestData memory _attData = AttestationRequestData({ 131 | recipient: alice, 132 | expirationTime: NO_EXPIRATION_TIME, 133 | revocable: true, 134 | refUID: EMPTY_UID, 135 | data: abi.encode(badge, ""), 136 | value: value 137 | }); 138 | 139 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 140 | 141 | vm.expectRevert(EAS.NotPayable.selector); 142 | eas.attest{value: value}(_req); 143 | } 144 | 145 | function testAttestRejectOtherSchema() external { 146 | // register other schema with the same resolver 147 | bytes32 otherSchema = registry.register("address badge", resolver, true); 148 | 149 | AttestationRequestData memory _attData = AttestationRequestData({ 150 | recipient: alice, 151 | expirationTime: NO_EXPIRATION_TIME, 152 | revocable: true, 153 | refUID: EMPTY_UID, 154 | data: abi.encode(badge, ""), 155 | value: 0 156 | }); 157 | 158 | AttestationRequest memory _req = AttestationRequest({schema: otherSchema, data: _attData}); 159 | 160 | vm.expectRevert(UnknownSchema.selector); 161 | eas.attest(_req); 162 | } 163 | 164 | function testAttestRejectInvalidPayload(bytes memory randomPayload) external { 165 | // note: randomPayload != abi.encode(badge, _) 166 | 167 | AttestationRequestData memory _attData = AttestationRequestData({ 168 | recipient: alice, 169 | expirationTime: NO_EXPIRATION_TIME, 170 | revocable: true, 171 | refUID: EMPTY_UID, 172 | data: randomPayload, 173 | value: 0 174 | }); 175 | 176 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 177 | 178 | // fail on abi.decode, no error 179 | vm.expectRevert(bytes("")); 180 | eas.attest(_req); 181 | } 182 | 183 | function testAttestRejectNonContractBadge(address otherBadge) external { 184 | vm.assume(otherBadge != address(badge)); 185 | vm.assume(otherBadge.code.length == 0); 186 | 187 | bytes memory data = abi.encode(otherBadge, ""); 188 | 189 | AttestationRequestData memory _attData = AttestationRequestData({ 190 | recipient: alice, 191 | expirationTime: NO_EXPIRATION_TIME, 192 | revocable: true, 193 | refUID: EMPTY_UID, 194 | data: data, 195 | value: 0 196 | }); 197 | 198 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 199 | 200 | vm.expectRevert(abi.encodeWithSelector(BadgeNotFound.selector, otherBadge)); 201 | eas.attest(_req); 202 | } 203 | 204 | function testAttestRejectUnknownBadge() external { 205 | ScrollBadge otherBadge = new TestContract(address(resolver)); 206 | bytes memory data = abi.encode(otherBadge, ""); 207 | 208 | AttestationRequestData memory _attData = AttestationRequestData({ 209 | recipient: alice, 210 | expirationTime: NO_EXPIRATION_TIME, 211 | revocable: true, 212 | refUID: EMPTY_UID, 213 | data: data, 214 | value: 0 215 | }); 216 | 217 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 218 | 219 | vm.expectRevert(abi.encodeWithSelector(BadgeNotAllowed.selector, otherBadge)); 220 | eas.attest(_req); 221 | } 222 | 223 | function testAttestRejectIncorrectBadgeContract() external { 224 | address otherBadge = address(this); // this is not a badge 225 | resolver.toggleBadge(otherBadge, true); 226 | bytes memory data = abi.encode(otherBadge, ""); 227 | 228 | AttestationRequestData memory _attData = AttestationRequestData({ 229 | recipient: alice, 230 | expirationTime: NO_EXPIRATION_TIME, 231 | revocable: true, 232 | refUID: EMPTY_UID, 233 | data: data, 234 | value: 0 235 | }); 236 | 237 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 238 | 239 | // fail on issueBadge call, no error 240 | vm.expectRevert(bytes("")); 241 | eas.attest(_req); 242 | } 243 | 244 | function testRevokeRejectPayment(uint256 value) external { 245 | vm.assume(value > 0); 246 | vm.deal(address(this), value); 247 | 248 | bytes32 uid = _attest(address(badge), "", alice); 249 | 250 | RevocationRequestData memory _data = RevocationRequestData({uid: uid, value: value}); 251 | 252 | RevocationRequest memory _req = RevocationRequest({schema: schema, data: _data}); 253 | 254 | vm.expectRevert(EAS.NotPayable.selector); 255 | eas.revoke{value: value}(_req); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /test/ProfileRegistry.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Test} from "forge-std/Test.sol"; 6 | import {VmSafe} from "forge-std/Vm.sol"; 7 | 8 | import {EAS, IEAS} from "@eas/contracts/EAS.sol"; 9 | import {SchemaRegistry, ISchemaRegistry} from "@eas/contracts/SchemaRegistry.sol"; 10 | 11 | import { 12 | ITransparentUpgradeableProxy, 13 | TransparentUpgradeableProxy 14 | } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; 15 | 16 | import {EmptyContract} from "../src/misc/EmptyContract.sol"; 17 | import {Profile} from "../src/profile/Profile.sol"; 18 | import {ProfileRegistryMintable} from "../src/profile/ProfileRegistry.sol"; 19 | import {ScrollBadgeResolver} from "../src/resolver/ScrollBadgeResolver.sol"; 20 | 21 | contract ProfileRegistryTest is Test { 22 | error CallerIsNotUserProfile(); 23 | error DuplicatedUsername(); 24 | error ExpiredSignature(); 25 | error ImplementationNotContract(); 26 | error InvalidReferrer(); 27 | error InvalidSignature(); 28 | error InvalidUsername(); 29 | error MsgValueMismatchWithMintFee(); 30 | error ProfileAlreadyMinted(); 31 | 32 | address private constant TREASURY_ADDRESS = 0x1000000000000000000000000000000000000000; 33 | 34 | address private constant PROXY_ADMIN_ADDRESS = 0x2000000000000000000000000000000000000000; 35 | 36 | ISchemaRegistry internal schemaRegistry; 37 | IEAS internal eas; 38 | ScrollBadgeResolver internal resolver; 39 | 40 | VmSafe.Wallet private signer; 41 | 42 | Profile private profileImpl; 43 | ProfileRegistryMintable private profileRegistry; 44 | 45 | receive() external payable {} 46 | 47 | function setUp() public { 48 | schemaRegistry = new SchemaRegistry(); 49 | eas = new EAS(schemaRegistry); 50 | address profileRegistryProxy = 51 | address(new TransparentUpgradeableProxy(address(new EmptyContract()), PROXY_ADMIN_ADDRESS, "")); 52 | 53 | address resolverImpl = address(new ScrollBadgeResolver(address(eas), profileRegistryProxy)); 54 | address resolverProxy = address(new TransparentUpgradeableProxy(resolverImpl, PROXY_ADMIN_ADDRESS, "")); 55 | resolver = ScrollBadgeResolver(payable(resolverProxy)); 56 | resolver.initialize(); 57 | 58 | signer = vm.createWallet(10_001); 59 | 60 | profileImpl = new Profile(address(resolver)); 61 | ProfileRegistryMintable profileRegistryImpl = new ProfileRegistryMintable(); 62 | vm.prank(PROXY_ADMIN_ADDRESS); 63 | ITransparentUpgradeableProxy(profileRegistryProxy).upgradeTo(address(profileRegistryImpl)); 64 | profileRegistry = ProfileRegistryMintable(profileRegistryProxy); 65 | profileRegistry.initialize(TREASURY_ADDRESS, signer.addr, address(profileImpl)); 66 | vm.warp(1_000_000); 67 | } 68 | 69 | function testInitialize() external { 70 | vm.expectRevert("Initializable: contract is already initialized"); 71 | profileRegistry.initialize(address(0), address(0), address(0)); 72 | } 73 | 74 | function testMint() external { 75 | // MsgValueMismatchWithMintFee 76 | vm.expectRevert(MsgValueMismatchWithMintFee.selector); 77 | profileRegistry.mint("x", new bytes(0)); 78 | // should revert when invalid username: length < 4 79 | vm.expectRevert(InvalidUsername.selector); 80 | profileRegistry.mint{value: 0.001 ether}("x", new bytes(0)); 81 | // should revert when invalid username: length > 15 82 | vm.expectRevert(InvalidUsername.selector); 83 | profileRegistry.mint{value: 0.001 ether}("xxxxxyyyyyzzzzza", new bytes(0)); 84 | // should revert when invalid username: has characters other than a-z, A-Z, 0-9, _ 85 | vm.expectRevert(InvalidUsername.selector); 86 | profileRegistry.mint{value: 0.001 ether}("xxxxx.xxxxx", new bytes(0)); 87 | 88 | // should revert when ExpiredSignature 89 | uint256 deadline = block.timestamp - 1; 90 | bytes memory signature = _signReferralData(signer.privateKey, address(this), address(this), deadline); 91 | vm.expectRevert(ExpiredSignature.selector); 92 | profileRegistry.mint("xxxxx", abi.encode(address(this), deadline, signature)); 93 | 94 | // should mint without referral and fee goes to treasury 95 | uint256 balanceBefore = TREASURY_ADDRESS.balance; 96 | assertEq(profileRegistry.isProfileMinted(profileRegistry.getProfile(address(this))), false); 97 | assertEq(profileRegistry.isUsernameUsed("xxxxx"), false); 98 | profileRegistry.mint{value: 0.001 ether}("xxxxx", new bytes(0)); 99 | assertEq(profileRegistry.isUsernameUsed("xxxxx"), true); 100 | assertEq(profileRegistry.isProfileMinted(profileRegistry.getProfile(address(this))), true); 101 | uint256 balanceAfter = TREASURY_ADDRESS.balance; 102 | assertEq(balanceAfter - balanceBefore, 0.001 ether); 103 | 104 | // should revert when mint with same sender 105 | vm.expectRevert(ProfileAlreadyMinted.selector); 106 | profileRegistry.mint{value: 0.001 ether}("yyyyy", new bytes(0)); 107 | 108 | // should revert when InvalidReferrer 109 | deadline = block.timestamp + 1; 110 | signature = _signReferralData(signer.privateKey + 1, address(1), address(this), deadline); 111 | vm.expectRevert(InvalidReferrer.selector); 112 | profileRegistry.mint("xxxxx", abi.encode(address(1), deadline, signature)); 113 | 114 | // should revert when InvalidSignature 115 | deadline = block.timestamp + 1; 116 | signature = _signReferralData(signer.privateKey + 1, address(this), address(this), deadline); 117 | vm.expectRevert(InvalidSignature.selector); 118 | profileRegistry.mint("xxxxx", abi.encode(address(this), deadline, signature)); 119 | 120 | // should mint with referral and fee goes to referral 121 | (uint128 referred, uint128 earned) = profileRegistry.referrerData(address(this)); 122 | assertEq(referred, 0); 123 | assertEq(earned, 0); 124 | deadline = block.timestamp + 1; 125 | signature = _signReferralData(signer.privateKey, address(this), address(2), deadline); 126 | payable(address(2)).transfer(1 ether); 127 | vm.prank(address(2)); 128 | balanceBefore = address(this).balance; 129 | profileRegistry.mint{value: 0.0005 ether}("yyyyy", abi.encode(address(this), deadline, signature)); 130 | balanceAfter = address(this).balance; 131 | assertEq(balanceAfter - balanceBefore, 0.0005 ether); 132 | (referred, earned) = profileRegistry.referrerData(address(this)); 133 | assertEq(referred, 1); 134 | assertEq(earned, 0.0005 ether); 135 | vm.stopPrank(); 136 | 137 | // should revert when mint with same name 138 | payable(address(3)).transfer(1 ether); 139 | vm.prank(address(3)); 140 | vm.expectRevert(DuplicatedUsername.selector); 141 | profileRegistry.mint{value: 0.001 ether}("yyyyy", new bytes(0)); 142 | vm.stopPrank(); 143 | } 144 | 145 | function testRegisterUsername() external { 146 | vm.expectRevert(CallerIsNotUserProfile.selector); 147 | profileRegistry.registerUsername("xxxx"); 148 | } 149 | 150 | function testUnregisterUsername() external { 151 | vm.expectRevert(CallerIsNotUserProfile.selector); 152 | profileRegistry.unregisterUsername("xxxx"); 153 | } 154 | 155 | function testBlacklistUsername() external { 156 | vm.expectRevert("Ownable: caller is not the owner"); 157 | vm.prank(address(1)); 158 | profileRegistry.blacklistUsername(new bytes32[](0)); 159 | vm.stopPrank(); 160 | 161 | bytes32[] memory v = new bytes32[](1); 162 | v[0] = keccak256("xxxxx"); 163 | assertEq(profileRegistry.isUsernameUsed("xxxxx"), false); 164 | profileRegistry.blacklistUsername(v); 165 | assertEq(profileRegistry.isUsernameUsed("xxxxx"), true); 166 | } 167 | 168 | function testUpdateDefaultProfileAvatar(string memory newAvatar) external { 169 | vm.expectRevert("Ownable: caller is not the owner"); 170 | vm.prank(address(1)); 171 | profileRegistry.updateDefaultProfileAvatar(newAvatar); 172 | vm.stopPrank(); 173 | 174 | assertEq(profileRegistry.getDefaultProfileAvatar(), ""); 175 | profileRegistry.updateDefaultProfileAvatar(newAvatar); 176 | assertEq(profileRegistry.getDefaultProfileAvatar(), newAvatar); 177 | } 178 | 179 | function testUpdateProfileImplementation() external { 180 | vm.expectRevert("Ownable: caller is not the owner"); 181 | vm.prank(address(1)); 182 | profileRegistry.updateProfileImplementation(address(0)); 183 | vm.stopPrank(); 184 | 185 | vm.expectRevert(ImplementationNotContract.selector); 186 | profileRegistry.updateProfileImplementation(address(0)); 187 | 188 | Profile newProfileImpl = new Profile(address(resolver)); 189 | assertEq(profileRegistry.implementation(), address(profileImpl)); 190 | profileRegistry.updateProfileImplementation(address(newProfileImpl)); 191 | assertEq(profileRegistry.implementation(), address(newProfileImpl)); 192 | } 193 | 194 | function testUpdateSigner(address newSigner) external { 195 | vm.expectRevert("Ownable: caller is not the owner"); 196 | vm.prank(address(1)); 197 | profileRegistry.updateSigner(newSigner); 198 | vm.stopPrank(); 199 | 200 | assertEq(profileRegistry.signer(), signer.addr); 201 | profileRegistry.updateSigner(newSigner); 202 | assertEq(profileRegistry.signer(), newSigner); 203 | } 204 | 205 | function testUpdateTreasury(address newTreasury) external { 206 | vm.expectRevert("Ownable: caller is not the owner"); 207 | vm.prank(address(1)); 208 | profileRegistry.updateTreasury(newTreasury); 209 | vm.stopPrank(); 210 | 211 | assertEq(profileRegistry.treasury(), TREASURY_ADDRESS); 212 | profileRegistry.updateTreasury(newTreasury); 213 | assertEq(profileRegistry.treasury(), newTreasury); 214 | } 215 | 216 | function _signReferralData(uint256 privateKey, address referrer, address owner, uint256 deadline) 217 | private 218 | view 219 | returns (bytes memory) 220 | { 221 | bytes32 TYPE_HASH = 222 | keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); 223 | bytes32 DOMAIN_SEPARATOR = keccak256( 224 | abi.encode(TYPE_HASH, keccak256("ProfileRegistry"), keccak256("1"), block.chainid, address(profileRegistry)) 225 | ); 226 | bytes32 REFERRAL_TYPEHASH = keccak256("Referral(address referrer,address owner,uint256 deadline)"); 227 | bytes32 structHash = keccak256(abi.encode(REFERRAL_TYPEHASH, referrer, owner, deadline)); 228 | bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); 229 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); 230 | return abi.encodePacked(r, s, v); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /test/SCRHoldingBadge.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {Test} from "forge-std/Test.sol"; 6 | import {MockERC20} from "forge-std/mocks/MockERC20.sol"; 7 | 8 | import {EAS} from "@eas/contracts/EAS.sol"; 9 | import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 10 | import {SchemaRegistry, ISchemaRegistry} from "@eas/contracts/SchemaRegistry.sol"; 11 | import {IEAS, Attestation, AttestationRequest, AttestationRequestData, RevocationRequest, RevocationRequestData} from "@eas/contracts/IEAS.sol"; 12 | 13 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 14 | 15 | import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; 16 | 17 | import {EmptyContract} from "../src/misc/EmptyContract.sol"; 18 | import {Profile} from "../src/profile/Profile.sol"; 19 | import {ProfileRegistryMintable} from "../src/profile/ProfileRegistry.sol"; 20 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 21 | import {ScrollBadgeResolver} from "../src/resolver/ScrollBadgeResolver.sol"; 22 | import {SCRHoldingBadge} from "../src/badge/examples/SCRHoldingBadge.sol"; 23 | 24 | import {encodeBadgeData} from "../src/Common.sol"; 25 | import {AttestationNotFound} from "../src/Errors.sol"; 26 | 27 | contract Token is MockERC20 { 28 | function mint(address to, uint256 amount) external { 29 | _mint(to, amount); 30 | } 31 | } 32 | 33 | contract SCRHoldingBadgeTest is Test { 34 | address private constant TREASURY_ADDRESS = 0x1000000000000000000000000000000000000000; 35 | 36 | address private constant PROXY_ADMIN_ADDRESS = 0x2000000000000000000000000000000000000000; 37 | 38 | ISchemaRegistry private schemaRegistry; 39 | IEAS private eas; 40 | ScrollBadgeResolver private resolver; 41 | SCRHoldingBadge private badge; 42 | Token private token; 43 | 44 | Profile private profileImpl; 45 | ProfileRegistryMintable private profileRegistry; 46 | Profile private profile; 47 | 48 | receive() external payable {} 49 | 50 | function setUp() public { 51 | schemaRegistry = new SchemaRegistry(); 52 | eas = new EAS(schemaRegistry); 53 | address profileRegistryProxy = address( 54 | new TransparentUpgradeableProxy(address(new EmptyContract()), PROXY_ADMIN_ADDRESS, "") 55 | ); 56 | 57 | address resolverImpl = address(new ScrollBadgeResolver(address(eas), profileRegistryProxy)); 58 | address resolverProxy = address(new TransparentUpgradeableProxy(resolverImpl, PROXY_ADMIN_ADDRESS, "")); 59 | resolver = ScrollBadgeResolver(payable(resolverProxy)); 60 | resolver.initialize(); 61 | 62 | token = new Token(); 63 | badge = new SCRHoldingBadge(address(resolver), "xx", address(token)); 64 | resolver.updateSelfAttestedBadge(0, address(badge)); 65 | 66 | profileImpl = new Profile(address(resolver)); 67 | ProfileRegistryMintable profileRegistryImpl = new ProfileRegistryMintable(); 68 | vm.prank(PROXY_ADMIN_ADDRESS); 69 | ITransparentUpgradeableProxy(profileRegistryProxy).upgradeTo(address(profileRegistryImpl)); 70 | profileRegistry = ProfileRegistryMintable(profileRegistryProxy); 71 | profileRegistry.initialize(TREASURY_ADDRESS, TREASURY_ADDRESS, address(profileImpl)); 72 | profile = Profile(profileRegistry.mint{value: 0.001 ether}("xxxxx", new bytes(0))); 73 | } 74 | 75 | function testInitialize() external view { 76 | // from ScrollBadge 77 | assertEq(badge.resolver(), address(resolver)); 78 | 79 | // from ScrollBadgeCustomPayload 80 | assertEq(badge.getSchema(), "uint256 level"); 81 | 82 | // from ScrollBadgeDefaultURI 83 | assertEq(badge.defaultBadgeURI(), "xx"); 84 | assertEq(badge.badgeTokenURI(0), "xx"); 85 | 86 | // from SCRHoldingBadge 87 | assertEq(badge.scr(), address(token)); 88 | assertEq(badge.getBadgeId(), 0); 89 | 90 | // in ScrollBadgeResolver 91 | assertEq(resolver.selfAttestedBadges(0), address(badge)); 92 | } 93 | 94 | function testIssueBadge(Attestation calldata attestation) external { 95 | vm.prank(address(resolver)); 96 | assertEq(false, badge.issueBadge(attestation)); 97 | } 98 | 99 | function testRevokeBadge(Attestation calldata attestation) external { 100 | vm.prank(address(resolver)); 101 | assertEq(false, badge.revokeBadge(attestation)); 102 | } 103 | 104 | function testGetAndValidateBadge() external { 105 | bytes32 uid; 106 | // badge id nonzero 107 | assembly { 108 | uid := 0 109 | uid := or(uid, shl(1, 160)) 110 | } 111 | vm.expectRevert(abi.encodePacked(AttestationNotFound.selector, uid)); 112 | badge.getAndValidateBadge(uid); 113 | 114 | // customized data nonzero 115 | assembly { 116 | uid := 0 117 | uid := or(uid, shl(1, 192)) 118 | } 119 | vm.expectRevert(abi.encodePacked(AttestationNotFound.selector, uid)); 120 | badge.getAndValidateBadge(uid); 121 | 122 | // no scr 123 | assembly { 124 | uid := address() 125 | } 126 | token.mint(address(this), 1 ether - 1); 127 | vm.expectRevert(abi.encodePacked(AttestationNotFound.selector, uid)); 128 | badge.getAndValidateBadge(uid); 129 | 130 | // succeed 131 | assembly { 132 | uid := address() 133 | } 134 | token.mint(address(this), 1 ether); 135 | Attestation memory attestation = badge.getAndValidateBadge(uid); 136 | assertEq(attestation.uid, uid); 137 | assertEq(attestation.schema, resolver.schema()); 138 | assertEq(attestation.time, block.timestamp); 139 | assertEq(attestation.expirationTime, 0); 140 | assertEq(attestation.refUID, bytes32(0)); 141 | assertEq(attestation.recipient, address(this)); 142 | assertEq(attestation.attester, address(badge)); 143 | assertEq(attestation.revocable, false); 144 | assertEq(attestation.data, encodeBadgeData(address(badge), abi.encode(uint256(1)))); 145 | } 146 | 147 | function testBadgeTokenURI(address user, uint256 amount) external { 148 | vm.assume(amount >= 1 ether); 149 | vm.assume(user != address(0)); 150 | 151 | uint256 level; 152 | if (amount >= 1 ether) level = 1; 153 | if (amount >= 10 ether) level = 2; 154 | if (amount >= 100 ether) level = 3; 155 | if (amount >= 1000 ether) level = 4; 156 | if (amount >= 10000 ether) level = 5; 157 | if (amount >= 100000 ether) level = 6; 158 | 159 | token.mint(user, amount); 160 | bytes32 uid; 161 | assembly { 162 | uid := user 163 | } 164 | assertEq(badge.badgeTokenURI(uid), string(abi.encodePacked("xx", Strings.toString(level), ".json"))); 165 | } 166 | 167 | function testHasBadge(address user, uint256 amount) external { 168 | vm.assume(user != address(0)); 169 | 170 | token.mint(user, amount); 171 | assertEq(badge.hasBadge(user), amount >= 1 ether); 172 | } 173 | 174 | function testGetAttestationInvalidUID(address user, uint96 base) external view { 175 | vm.assume(base > 0); 176 | bytes32 uid; 177 | assembly { 178 | uid := or(user, shl(160, base)) 179 | } 180 | Attestation memory attestation = badge.getAttestation(uid); 181 | assertEq(attestation.uid, bytes32(0)); 182 | assertEq(attestation.schema, ""); 183 | assertEq(attestation.time, 0); 184 | assertEq(attestation.expirationTime, 0); 185 | assertEq(attestation.refUID, bytes32(0)); 186 | assertEq(attestation.recipient, address(0)); 187 | assertEq(attestation.attester, address(0)); 188 | assertEq(attestation.revocable, false); 189 | assertEq(attestation.data, ""); 190 | } 191 | 192 | function testGetAttestationNoSCR(address user, uint256 amount) external { 193 | amount = bound(amount, 0, 1 ether - 1); 194 | token.mint(user, amount); 195 | bytes32 uid; 196 | assembly { 197 | uid := user 198 | } 199 | Attestation memory attestation = badge.getAttestation(uid); 200 | _validateAttestation(attestation, user); 201 | } 202 | 203 | function testGetAttestation(address user, uint256 amount, uint256 amount2) external { 204 | vm.assume(amount >= 1 ether); 205 | vm.assume(user != address(0)); 206 | amount2 = bound(amount2, 0, amount); 207 | 208 | uint256 level; 209 | if (amount >= 1 ether) level = 1; 210 | if (amount >= 10 ether) level = 2; 211 | if (amount >= 100 ether) level = 3; 212 | if (amount >= 1000 ether) level = 4; 213 | if (amount >= 10000 ether) level = 5; 214 | if (amount >= 100000 ether) level = 6; 215 | 216 | token.mint(user, amount); 217 | bytes32 uid; 218 | assembly { 219 | uid := user 220 | } 221 | Attestation memory attestation = badge.getAttestation(uid); 222 | _validateAttestation(attestation, user); 223 | 224 | // transfer 225 | vm.prank(user); 226 | token.transfer(address(this), amount2); 227 | attestation = badge.getAttestation(uid); 228 | _validateAttestation(attestation, user); 229 | } 230 | 231 | function _validateAttestation(Attestation memory attestation, address user) internal view { 232 | uint256 amount = token.balanceOf(user); 233 | if (amount < 1 ether) { 234 | assertEq(attestation.uid, bytes32(0)); 235 | assertEq(attestation.schema, ""); 236 | assertEq(attestation.time, 0); 237 | assertEq(attestation.expirationTime, 0); 238 | assertEq(attestation.refUID, bytes32(0)); 239 | assertEq(attestation.recipient, address(0)); 240 | assertEq(attestation.attester, address(0)); 241 | assertEq(attestation.revocable, false); 242 | assertEq(attestation.data, ""); 243 | } else { 244 | bytes32 uid; 245 | assembly { 246 | uid := user 247 | } 248 | uint256 level; 249 | if (amount >= 1 ether) level = 1; 250 | if (amount >= 10 ether) level = 2; 251 | if (amount >= 100 ether) level = 3; 252 | if (amount >= 1000 ether) level = 4; 253 | if (amount >= 10000 ether) level = 5; 254 | if (amount >= 100000 ether) level = 6; 255 | assertEq(attestation.uid, uid); 256 | assertEq(attestation.schema, resolver.schema()); 257 | assertEq(attestation.time, block.timestamp); 258 | assertEq(attestation.expirationTime, 0); 259 | assertEq(attestation.refUID, bytes32(0)); 260 | assertEq(attestation.recipient, user); 261 | assertEq(attestation.attester, address(badge)); 262 | assertEq(attestation.revocable, false); 263 | assertEq(attestation.data, encodeBadgeData(address(badge), abi.encode(level))); 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /docs/canvas-interaction-guide.md: -------------------------------------------------------------------------------- 1 | # Canvas Interaction Guide 2 | 3 | This document will show you the basic steps how one would interact with a Canvas profile. 4 | 5 | In the examples on this page, we use the configurations from [Deployments](./deployments.md), as well as the following values: 6 | 7 | ```bash 8 | # Canvas badges -- each badge type is a new contract, here we only have three simple test contracts 9 | SCROLL_SEPOLIA_SIMPLE_BADGE_A_ADDRESS="0x30C98067517f8ee38e748A3aF63429974103Ea6B" 10 | SCROLL_SEPOLIA_SIMPLE_BADGE_B_ADDRESS="0xeBFc9B95328B2Cdb3c4CA8913e329c101d2Abbc2" 11 | SCROLL_SEPOLIA_SIMPLE_BADGE_C_ADDRESS="0x64492EF5a60245fbaF65F69782FCf158F3a8e3Aa" 12 | 13 | # Canvas profiles -- each user has their own profile (a smart contract), here we provide a simple test profile 14 | SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS="0xa10561B0b0f9F66Ec18A1Eff58e6F37D59dbbdeC" 15 | ``` 16 | 17 | The following examples use Foundry's `cast`, but the same queries can be made using curl, ethers, etc. analogously. 18 | 19 | 20 | ### How to check if a user has minted their profile yet? 21 | 22 | We first query the user's deterministic profile address, then see if the profile has been minted or not. 23 | 24 | ```bash 25 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_PROFILE_REGISTRY_ADDRESS" "getProfile(address)(address)" "0xF138EdC6038C237e94450bcc9a7085a7b213cAf0" 26 | 0xa10561B0b0f9F66Ec18A1Eff58e6F37D59dbbdeC 27 | 28 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_PROFILE_REGISTRY_ADDRESS" "isProfileMinted(address)(bool)" "0xa10561B0b0f9F66Ec18A1Eff58e6F37D59dbbdeC" 29 | false 30 | ``` 31 | 32 | 33 | ### How to mint a profile? 34 | 35 | Mint a profile without referral: 36 | 37 | ```bash 38 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_PROFILE_REGISTRY_ADDRESS" "mint(string,bytes)" "username1" "0x" --value "0.001ether" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY" 39 | ``` 40 | 41 | To mint a profile with a referral, produce a signed referral, then submit it along with the `mint` call (see [referral.js](../examples/src/referral.js) for details). 42 | 43 | ```bash 44 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_PROFILE_REGISTRY_ADDRESS" "mint(string,bytes)" "username2" "0x000000000000000000000000f138edc6038c237e94450bcc9a7085a7b213caf00000000000000000000000000000000000000000000000000000000065e194a500000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000041dbced15b87df9b122ae418b3189b39a46c542daf4a724b57fb796670ece2dcdc652a1ae20a6a459e85b77fd4135dc4b90c4eac5352b555c0e42c8d8b8999e64e1c00000000000000000000000000000000000000000000000000000000000000" --value "0.0005ether" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY2" 45 | ``` 46 | 47 | 48 | ### How to query and change the username? 49 | 50 | ```bash 51 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "username()(string)" 52 | "username1" 53 | 54 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "changeUsername(string)" "username2" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY" 55 | ``` 56 | 57 | 58 | ### How to list all badges that a user has? 59 | 60 | We can use the [EAS GraphQL API](https://docs.attest.org/docs/developer-tools/api) to query a user's Canvas badges. 61 | 62 | > Warning: Badges are minted to the user's wallet address, not to their profile address! 63 | 64 | ``` 65 | query Attestation { 66 | attestations( 67 | where: { 68 | schemaId: { equals: "0xa35b5470ebb301aa5d309a8ee6ea258cad680ea112c86e456d5f2254448afc74" }, 69 | recipient: { equals: "0xF138EdC6038C237e94450bcc9a7085a7b213cAf0" }, 70 | revoked: { equals: false } 71 | } 72 | ) { 73 | attester 74 | data 75 | id 76 | time 77 | txid 78 | } 79 | } 80 | ``` 81 | 82 | See https://studio.apollographql.com/sandbox/explorer for more query options. 83 | 84 | Request: 85 | 86 | ```bash 87 | > curl --request POST --header 'content-type: application/json' --url "$SCROLL_SEPOLIA_EAS_GRAPHQL_URL" --data-binary @- << EOF 88 | { 89 | "query": " \ 90 | query Attestation { \ 91 | attestations( \ 92 | where: { \ 93 | schemaId: { equals: \"0xa35b5470ebb301aa5d309a8ee6ea258cad680ea112c86e456d5f2254448afc74\" }, \ 94 | recipient: { equals: \"0xF138EdC6038C237e94450bcc9a7085a7b213cAf0\" }, \ 95 | revoked: { equals: false } \ 96 | } \ 97 | ) { \ 98 | attester \ 99 | data \ 100 | id \ 101 | time \ 102 | txid \ 103 | } \ 104 | } \ 105 | " 106 | } 107 | EOF 108 | ``` 109 | 110 | Response: 111 | 112 | ```json 113 | { 114 | "data": { 115 | "attestations": [ 116 | { 117 | "attester": "0xF138EdC6038C237e94450bcc9a7085a7b213cAf0", 118 | "data": "0x00000000000000000000000030c98067517f8ee38e748a3af63429974103ea6b00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", 119 | "id": "0x719876cb21ec011354a230c9446ee0dadfe716127f56ef997850fc14231787b0", 120 | "time": 1712751313, 121 | "txid": "0x1c732351884b1fa0d9f9828190ca430763e3408f62a25f3c46efc52597268ceb" 122 | }, 123 | { 124 | "attester": "0xF138EdC6038C237e94450bcc9a7085a7b213cAf0", 125 | "data": "0x000000000000000000000000ebfc9b95328b2cdb3c4ca8913e329c101d2abbc200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", 126 | "id": "0xb3e474b7bed202a54d1c922635fb999abe8432b654a3410be7d5b578ea788fdd", 127 | "time": 1712751325, 128 | "txid": "0x12410df5c994e744d2e4f7f22bb389937ffd2dd12a3be02164dfbd0703f3f5fe" 129 | }, 130 | { 131 | "attester": "0xF138EdC6038C237e94450bcc9a7085a7b213cAf0", 132 | "data": "0x00000000000000000000000064492ef5a60245fbaf65f69782fcf158f3a8e3aa00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", 133 | "id": "0x80b82bdd262be13a673d1e8684c97b41646cf6168232cd6b600802e4e8d06a54", 134 | "time": 1712751427, 135 | "txid": "0xa2575ccaba7b9f89a1e056bea28573fa0b70b077bca0e3f19f1dc2f88e89e395" 136 | } 137 | ] 138 | } 139 | } 140 | ``` 141 | 142 | 143 | ### How to decode the badge payload? 144 | 145 | Each badge is an attestation, whose `data` field contains the abi-encoded badge payload, using the following schema: 146 | 147 | ``` 148 | address badge, bytes payload 149 | ``` 150 | 151 | `badge` is the badge contract address, while `payload` is additional application-specific data. 152 | 153 | ```bash 154 | > cast abi-decode "foo(address,bytes)" --input "0x00000000000000000000000030c98067517f8ee38e748a3af63429974103ea6b00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000" 155 | 0x30C98067517f8ee38e748A3aF63429974103Ea6B 156 | 0x 157 | ``` 158 | 159 | 160 | ### How to get a badge image? 161 | 162 | To get the token URI of a certain badge, first collect the badge attestation UID and the badge contract address. Then call `badgeTokenURI`: 163 | 164 | ```bash 165 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_SIMPLE_BADGE_A_ADDRESS" "badgeTokenURI(bytes32)(string)" "0x719876cb21ec011354a230c9446ee0dadfe716127f56ef997850fc14231787b0" 166 | "ipfs://bafybeibc5sgo2plmjkq2tzmhrn54bk3crhnc23zd2msg4ea7a4pxrkgfna/1" 167 | ``` 168 | 169 | The result is a badge token URI, which follows the same schema as ERC721 tokens: The token URI points to a JSON file with `name`, `description`, and `image` fields. The token URI can be a HTTP or IPFS link, or it can be a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs). 170 | 171 | To get the default token URI of a badge, simply call `badgeTokenURI` with the *zero UID*. The default badge token URI can be the same as the token URI of a specific badge, or it can be different, depending on the badge implementation. 172 | 173 | ```bash 174 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_SIMPLE_BADGE_A_ADDRESS" "badgeTokenURI(bytes32)(string)" "0x0000000000000000000000000000000000000000000000000000000000000000" 175 | "ipfs://bafybeibc5sgo2plmjkq2tzmhrn54bk3crhnc23zd2msg4ea7a4pxrkgfna/1" 176 | ``` 177 | 178 | 179 | ### How to check if a user has a certain badge or not? 180 | 181 | ```bash 182 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_SIMPLE_BADGE_A_ADDRESS" "hasBadge(address)(bool)" "0xF138EdC6038C237e94450bcc9a7085a7b213cAf0" 183 | true 184 | ``` 185 | 186 | 187 | ### How to configure a profile avatar? 188 | 189 | A user can use one of their own NFTs as their avatar. To do this, they need to provide the ERC721 contract address and the token ID. 190 | 191 | ```bash 192 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "changeAvatar(address,uint256)" "0x74670A3998d9d6622E32D0847fF5977c37E0eC91" "1" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY" 193 | ``` 194 | 195 | 196 | ### How to attach a badge? 197 | 198 | A user can attach one or more badges to their profile. Badges are referenced by their attestation UID. 199 | 200 | ```bash 201 | # attach one 202 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "attach(bytes32[])" "[0x719876cb21ec011354a230c9446ee0dadfe716127f56ef997850fc14231787b0]" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY" 203 | 204 | # attach many 205 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "attach(bytes32[])" "[0xb3e474b7bed202a54d1c922635fb999abe8432b654a3410be7d5b578ea788fdd,0x80b82bdd262be13a673d1e8684c97b41646cf6168232cd6b600802e4e8d06a54]" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY" 206 | 207 | # detach 208 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "detach(bytes32[])" "[0x80b82bdd262be13a673d1e8684c97b41646cf6168232cd6b600802e4e8d06a54]" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY" 209 | ``` 210 | 211 | 212 | ### How to query the attached badges? 213 | 214 | To see which badges are attached to a profile, we can call `getAttachedBadges`: 215 | 216 | ```bash 217 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "getAttachedBadges()(bytes32[])" 218 | [0x719876cb21ec011354a230c9446ee0dadfe716127f56ef997850fc14231787b0, 0xb3e474b7bed202a54d1c922635fb999abe8432b654a3410be7d5b578ea788fdd, 0x80b82bdd262be13a673d1e8684c97b41646cf6168232cd6b600802e4e8d06a54] 219 | ``` 220 | 221 | To get the order of the badges, we can call `getBadgeOrder`: 222 | 223 | ```bash 224 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "getBadgeOrder()(uint256[])" 225 | [1, 2, 3] 226 | ``` 227 | 228 | 229 | ### How to reorder the attached badges? 230 | 231 | Let's say the user has 3 badges attached: `A`, `B`, `C`. If we want to reorder these to `C`, `B`, `A`, we need to submit the following transaction: 232 | 233 | ```bash 234 | > cast send --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "reorderBadges(uint256[])" "[3, 2, 1]" --private-key "$SCROLL_SEPOLIA_PRIVATE_KEY" 235 | 236 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "getAttachedBadges()(bytes32[])" 237 | [0x719876cb21ec011354a230c9446ee0dadfe716127f56ef997850fc14231787b0, 0xb3e474b7bed202a54d1c922635fb999abe8432b654a3410be7d5b578ea788fdd, 0x80b82bdd262be13a673d1e8684c97b41646cf6168232cd6b600802e4e8d06a54] 238 | 239 | > cast call --rpc-url "$SCROLL_SEPOLIA_RPC_URL" "$SCROLL_SEPOLIA_TEST_PROFILE_ADDRESS" "getBadgeOrder()(uint256[])" 240 | [3, 2, 1] 241 | ``` 242 | -------------------------------------------------------------------------------- /src/profile/ProfileRegistry.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ECDSAUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol"; 6 | import {EIP712Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; 7 | import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; 8 | 9 | import {Address} from "@openzeppelin/contracts/utils/Address.sol"; 10 | import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; 11 | import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; 12 | import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; 13 | 14 | import {IProfileRegistry} from "../interfaces/IProfileRegistry.sol"; 15 | import {Profile} from "./Profile.sol"; 16 | 17 | import { 18 | CallerIsNotUserProfile, 19 | DuplicatedUsername, 20 | ExpiredSignature, 21 | ImplementationNotContract, 22 | InvalidReferrer, 23 | InvalidSignature, 24 | InvalidUsername, 25 | MsgValueMismatchWithMintFee, 26 | ProfileAlreadyMinted 27 | } from "../Errors.sol"; 28 | 29 | contract ClonableBeaconProxy is BeaconProxy { 30 | constructor() BeaconProxy(msg.sender, "") {} 31 | } 32 | 33 | /// @title ProfileRegistry 34 | /// @notice Profile registry keeps track of minted profiles and manages their implementation. 35 | contract ProfileRegistry is OwnableUpgradeable, EIP712Upgradeable, IBeacon, IProfileRegistry { 36 | /** 37 | * 38 | * Constants * 39 | * 40 | */ 41 | 42 | /// @notice The mint fee for each profile without referral. 43 | uint256 public constant MINT_FEE = 0.001 ether; 44 | 45 | /// @notice The codehash for `ClonableBeaconProxy` contract. 46 | bytes32 public constant cloneableProxyHash = keccak256(type(ClonableBeaconProxy).creationCode); 47 | 48 | /** 49 | * 50 | * Structs * 51 | * 52 | */ 53 | 54 | /// @param referred The number of profiles minted through this referrer. 55 | /// @param earned The amount of ETH earned by referral. 56 | struct ReferrerData { 57 | uint128 referred; 58 | uint128 earned; 59 | } 60 | 61 | /** 62 | * 63 | * Variables * 64 | * 65 | */ 66 | 67 | /// @notice The address of fee treasury. 68 | address public treasury; 69 | 70 | /// @notice The address of referral data signer. 71 | address public signer; 72 | 73 | /// @inheritdoc IBeacon 74 | /// @dev The address of profile implementation contract. 75 | address public implementation; 76 | 77 | /// @inheritdoc IProfileRegistry 78 | mapping(address => bool) public isProfileMinted; 79 | 80 | /// @notice Mapping from username hash to the status. 81 | mapping(bytes32 => bool) private isUsernameHashUsed; 82 | 83 | /// @notice The token URI for default profile avatar. 84 | /// @dev It should follow the Metadata Standards by opensea: https://docs.opensea.io/docs/metadata-standards. 85 | string private defaultProfileAvatar; 86 | 87 | /// @notice Mapping from referrer address to referrer statistics. 88 | mapping(address => ReferrerData) public referrerData; 89 | 90 | /** 91 | * 92 | * Modifiers * 93 | * 94 | */ 95 | modifier onlyProfile() { 96 | if (!isProfileMinted[_msgSender()]) revert CallerIsNotUserProfile(); 97 | _; 98 | } 99 | 100 | /** 101 | * 102 | * Constructor * 103 | * 104 | */ 105 | constructor() { 106 | _disableInitializers(); 107 | } 108 | 109 | /// @param treasury_ The address of mint fee treasury. 110 | /// @param signer_ The address of referral data signer. 111 | /// @param profileImpl_ The address of profile implementation contract. 112 | function initialize(address treasury_, address signer_, address profileImpl_) external initializer { 113 | __Context_init(); 114 | __Ownable_init(); 115 | __EIP712_init("ProfileRegistry", "1"); 116 | 117 | _updateTreasury(treasury_); 118 | _updateSigner(signer_); 119 | _updateProfileImplementation(profileImpl_); 120 | } 121 | 122 | /** 123 | * 124 | * Public View Functions * 125 | * 126 | */ 127 | 128 | /// @inheritdoc IProfileRegistry 129 | function getProfile(address account) public view override returns (address) { 130 | bytes32 salt = keccak256(abi.encode(account)); 131 | return Create2.computeAddress(salt, cloneableProxyHash, address(this)); 132 | } 133 | 134 | /// @inheritdoc IProfileRegistry 135 | function isUsernameUsed(string calldata username) external view override returns (bool) { 136 | bytes32 hash = keccak256(bytes(username)); 137 | return isUsernameHashUsed[hash]; 138 | } 139 | 140 | /// @inheritdoc IProfileRegistry 141 | function getDefaultProfileAvatar() external view override returns (string memory) { 142 | return defaultProfileAvatar; 143 | } 144 | 145 | /** 146 | * 147 | * Public Mutating Functions * 148 | * 149 | */ 150 | 151 | /// @inheritdoc IProfileRegistry 152 | function registerUsername(string memory username) external override onlyProfile { 153 | _validateUsername(username); 154 | 155 | bytes32 hash = keccak256(bytes(username)); 156 | if (isUsernameHashUsed[hash]) revert DuplicatedUsername(); 157 | isUsernameHashUsed[hash] = true; 158 | 159 | emit RegisterUsername(_msgSender(), username); 160 | } 161 | 162 | /// @inheritdoc IProfileRegistry 163 | function unregisterUsername(string memory username) external override onlyProfile { 164 | bytes32 hash = keccak256(bytes(username)); 165 | isUsernameHashUsed[hash] = false; 166 | 167 | emit UnregisterUsername(_msgSender(), username); 168 | } 169 | 170 | /** 171 | * 172 | * Restricted Functions * 173 | * 174 | */ 175 | 176 | /// @notice Blacklist a list of usernames by given username hashes. 177 | /// @param hashes The list of username hashes to blacklist. 178 | function blacklistUsername(bytes32[] memory hashes) external onlyOwner { 179 | for (uint256 i = 0; i < hashes.length; i++) { 180 | isUsernameHashUsed[hashes[i]] = true; 181 | } 182 | } 183 | 184 | /// @notice Update the default profile avatar. 185 | /// @param newAvatar The new default profile avatar. 186 | function updateDefaultProfileAvatar(string memory newAvatar) external onlyOwner { 187 | string memory oldAvatar = defaultProfileAvatar; 188 | defaultProfileAvatar = newAvatar; 189 | 190 | emit UpdateDefaultProfileAvatar(oldAvatar, newAvatar); 191 | } 192 | 193 | /// @notice Update the profile implementation contract. 194 | /// @param newImplementation The address of new implementation. 195 | function updateProfileImplementation(address newImplementation) external onlyOwner { 196 | _updateProfileImplementation(newImplementation); 197 | } 198 | 199 | /// @notice Update referral data signer. 200 | /// @param newSigner The address of new signer. 201 | function updateSigner(address newSigner) external onlyOwner { 202 | _updateSigner(newSigner); 203 | } 204 | 205 | /// @notice Update mint fee treasury. 206 | /// @param newTreasury The address of new treasury. 207 | function updateTreasury(address newTreasury) external onlyOwner { 208 | _updateTreasury(newTreasury); 209 | } 210 | 211 | /** 212 | * 213 | * Internal Functions * 214 | * 215 | */ 216 | 217 | /// @dev Internal function to mint a profile with given account address and username. 218 | /// @param account The address of user to mint profile. 219 | /// @param username The username of the profile. 220 | function _mintProfile(address account, string calldata username, address referrer) internal returns (address) { 221 | // deployment will fail and this function will revert if contract `salt` is not unique 222 | bytes32 salt = keccak256(abi.encode(account)); 223 | address profile = address(new ClonableBeaconProxy{salt: salt}()); 224 | 225 | // mark the profile is minted 226 | isProfileMinted[profile] = true; 227 | 228 | Profile(profile).initialize(account, username); 229 | 230 | emit MintProfile(account, profile, referrer); 231 | 232 | return profile; 233 | } 234 | 235 | /// @dev Internal function to update the profile implementation contract. 236 | /// @param newImplementation The address of new implementation. 237 | function _updateProfileImplementation(address newImplementation) private { 238 | if (!Address.isContract(newImplementation)) revert ImplementationNotContract(); 239 | 240 | address oldImplementation = implementation; 241 | implementation = newImplementation; 242 | 243 | emit UpdateProfileImplementation(oldImplementation, newImplementation); 244 | } 245 | 246 | /// @dev Internal function to update referral data signer. 247 | /// @param newSigner The address of new signer. 248 | function _updateSigner(address newSigner) private { 249 | address oldSigner = signer; 250 | signer = newSigner; 251 | 252 | emit UpdateSigner(oldSigner, newSigner); 253 | } 254 | 255 | /// @dev Internal function to update mint fee treasury. 256 | /// @param newTreasury The address of new treasury. 257 | function _updateTreasury(address newTreasury) private { 258 | address oldTreasury = treasury; 259 | treasury = newTreasury; 260 | 261 | emit UpdateTreasury(oldTreasury, newTreasury); 262 | } 263 | 264 | /// @dev Internal function to validate the username. We only accept username consisting of 265 | /// lowercase and uppercase English letter (`a-z, A-Z`), digits (`0-9`) and underscore (`_`). 266 | /// 267 | /// @param username_ The username to validate. 268 | function _validateUsername(string memory username_) private pure { 269 | bytes memory s = bytes(username_); 270 | uint256 length = s.length; 271 | if (length < 4 || length > 15) revert InvalidUsername(); 272 | for (uint256 i = 0; i < length; i++) { 273 | if ( 274 | !( 275 | (bytes1(0x61) <= s[i] && s[i] <= bytes1(0x7a)) || (bytes1(0x41) <= s[i] && s[i] <= bytes1(0x5a)) 276 | || (bytes1(0x30) <= s[i] && s[i] <= bytes1(0x39)) || s[i] == bytes1(0x5f) 277 | ) 278 | ) revert InvalidUsername(); 279 | } 280 | } 281 | } 282 | 283 | contract ProfileRegistryMintable is ProfileRegistry { 284 | /** 285 | * 286 | * Constants * 287 | * 288 | */ 289 | 290 | // solhint-disable-next-line var-name-mixedcase 291 | bytes32 private constant _REFERRAL_TYPEHASH = keccak256("Referral(address referrer,address owner,uint256 deadline)"); 292 | 293 | /** 294 | * 295 | * Public Mutating Functions * 296 | * 297 | */ 298 | 299 | function mint(string calldata username, bytes memory referral) external payable returns (address) { 300 | address receiver = treasury; 301 | address referrer; 302 | uint256 mintFee = MINT_FEE; 303 | if (referral.length > 0) { 304 | uint256 deadline; 305 | bytes memory signature; 306 | (receiver, deadline, signature) = abi.decode(referral, (address, uint256, bytes)); 307 | if (deadline < block.timestamp) revert ExpiredSignature(); 308 | if (!isProfileMinted[getProfile(receiver)]) { 309 | revert InvalidReferrer(); 310 | } 311 | 312 | bytes32 structHash = keccak256(abi.encode(_REFERRAL_TYPEHASH, receiver, _msgSender(), deadline)); 313 | bytes32 hash = _hashTypedDataV4(structHash); 314 | address recovered = ECDSAUpgradeable.recover(hash, signature); 315 | if (signer != recovered) revert InvalidSignature(); 316 | 317 | // half mint fee and fee goes to referral 318 | mintFee = MINT_FEE / 2; 319 | referrer = receiver; 320 | } 321 | if (msg.value != mintFee) revert MsgValueMismatchWithMintFee(); 322 | Address.sendValue(payable(receiver), mintFee); 323 | 324 | if (isProfileMinted[getProfile(_msgSender())]) { 325 | revert ProfileAlreadyMinted(); 326 | } 327 | 328 | if (referrer != address(0)) { 329 | ReferrerData memory cached = referrerData[referrer]; 330 | cached.referred += 1; 331 | cached.earned += uint128(mintFee); 332 | referrerData[referrer] = cached; 333 | } 334 | 335 | return _mintProfile(_msgSender(), username, referrer); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /test/ScrollBadgeInheritanceChain.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.19; 4 | 5 | import {ScrollBadgeTestBase} from "./ScrollBadgeTestBase.sol"; 6 | 7 | import {Attestation, AttestationRequest, AttestationRequestData} from "@eas/contracts/IEAS.sol"; 8 | import {EAS} from "@eas/contracts/EAS.sol"; 9 | import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; 10 | 11 | import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; 12 | import {ScrollBadgeAccessControl} from "../src/badge/extensions/ScrollBadgeAccessControl.sol"; 13 | import {ScrollBadgeCustomPayload} from "../src/badge/extensions/ScrollBadgeCustomPayload.sol"; 14 | import {ScrollBadgeNoExpiry} from "../src/badge/extensions/ScrollBadgeNoExpiry.sol"; 15 | import {ScrollBadgeNonRevocable} from "../src/badge/extensions/ScrollBadgeNonRevocable.sol"; 16 | import {ScrollBadgeSBT} from "../src/badge/extensions/ScrollBadgeSBT.sol"; 17 | import {ScrollBadgeSelfAttest} from "../src/badge/extensions/ScrollBadgeSelfAttest.sol"; 18 | import {ScrollBadgeSingleton} from "../src/badge/extensions/ScrollBadgeSingleton.sol"; 19 | 20 | contract TestContractBase is ScrollBadge { 21 | bool succeed = true; 22 | 23 | constructor(address resolver_) ScrollBadge(resolver_) {} 24 | 25 | function badgeTokenURI(bytes32 /*uid*/ ) public pure override returns (string memory) { 26 | return ""; 27 | } 28 | 29 | function onIssueBadge(Attestation calldata attestation) internal virtual override returns (bool) { 30 | return super.onIssueBadge(attestation) && succeed; 31 | } 32 | 33 | function onRevokeBadge(Attestation calldata attestation) internal virtual override returns (bool) { 34 | return super.onRevokeBadge(attestation) && succeed; 35 | } 36 | 37 | function baseFail() external { 38 | succeed = false; 39 | } 40 | } 41 | 42 | contract TestContractAccessControl is TestContractBase, ScrollBadgeAccessControl { 43 | constructor(address resolver_) TestContractBase(resolver_) {} 44 | 45 | function onIssueBadge(Attestation calldata attestation) 46 | internal 47 | virtual 48 | override (TestContractBase, ScrollBadgeAccessControl) 49 | returns (bool) 50 | { 51 | return super.onIssueBadge(attestation); 52 | } 53 | 54 | function onRevokeBadge(Attestation calldata attestation) 55 | internal 56 | virtual 57 | override (TestContractBase, ScrollBadgeAccessControl) 58 | returns (bool) 59 | { 60 | return super.onRevokeBadge(attestation); 61 | } 62 | } 63 | 64 | contract ScrollBadgeAccessControlInheritanceChainTest is ScrollBadgeTestBase { 65 | TestContractAccessControl internal badge; 66 | 67 | function setUp() public virtual override { 68 | super.setUp(); 69 | 70 | badge = new TestContractAccessControl(address(resolver)); 71 | resolver.toggleBadge(address(badge), true); 72 | } 73 | 74 | function testAttestFails() external { 75 | badge.baseFail(); 76 | vm.expectRevert(EAS.InvalidAttestation.selector); 77 | _attest(address(badge), "", alice); 78 | } 79 | 80 | function testRevokeFails() external { 81 | badge.toggleAttester(address(this), true); 82 | bytes32 uid = _attest(address(badge), "", alice); 83 | badge.baseFail(); 84 | vm.expectRevert(EAS.InvalidRevocation.selector); 85 | _revoke(uid); 86 | } 87 | } 88 | 89 | contract TestContractCustomPayload is TestContractBase, ScrollBadgeCustomPayload { 90 | constructor(address resolver_) TestContractBase(resolver_) {} 91 | 92 | function onIssueBadge(Attestation calldata attestation) 93 | internal 94 | virtual 95 | override (TestContractBase, ScrollBadgeCustomPayload) 96 | returns (bool) 97 | { 98 | return super.onIssueBadge(attestation); 99 | } 100 | 101 | function onRevokeBadge(Attestation calldata attestation) 102 | internal 103 | virtual 104 | override (TestContractBase, ScrollBadgeCustomPayload) 105 | returns (bool) 106 | { 107 | return super.onRevokeBadge(attestation); 108 | } 109 | 110 | function getSchema() public pure override returns (string memory) { 111 | return "string abc"; 112 | } 113 | } 114 | 115 | contract ScrollBadgeCustomPayloadInheritanceChainTest is ScrollBadgeTestBase { 116 | TestContractCustomPayload internal badge; 117 | 118 | function setUp() public virtual override { 119 | super.setUp(); 120 | 121 | badge = new TestContractCustomPayload(address(resolver)); 122 | resolver.toggleBadge(address(badge), true); 123 | } 124 | 125 | function testAttestFails() external { 126 | badge.baseFail(); 127 | vm.expectRevert(EAS.InvalidAttestation.selector); 128 | _attest(address(badge), "abc", alice); 129 | } 130 | 131 | function testRevokeFails() external { 132 | bytes32 uid = _attest(address(badge), "abc", alice); 133 | badge.baseFail(); 134 | vm.expectRevert(EAS.InvalidRevocation.selector); 135 | _revoke(uid); 136 | } 137 | } 138 | 139 | contract TestContractNoExpiry is TestContractBase, ScrollBadgeNoExpiry { 140 | constructor(address resolver_) TestContractBase(resolver_) {} 141 | 142 | function onIssueBadge(Attestation calldata attestation) 143 | internal 144 | virtual 145 | override (TestContractBase, ScrollBadgeNoExpiry) 146 | returns (bool) 147 | { 148 | return super.onIssueBadge(attestation); 149 | } 150 | 151 | function onRevokeBadge(Attestation calldata attestation) 152 | internal 153 | virtual 154 | override (TestContractBase, ScrollBadgeNoExpiry) 155 | returns (bool) 156 | { 157 | return super.onRevokeBadge(attestation); 158 | } 159 | } 160 | 161 | contract ScrollBadgeNoExpiryInheritanceChainTest is ScrollBadgeTestBase { 162 | TestContractNoExpiry internal badge; 163 | 164 | function setUp() public virtual override { 165 | super.setUp(); 166 | 167 | badge = new TestContractNoExpiry(address(resolver)); 168 | resolver.toggleBadge(address(badge), true); 169 | } 170 | 171 | function testAttestFails() external { 172 | badge.baseFail(); 173 | vm.expectRevert(EAS.InvalidAttestation.selector); 174 | _attest(address(badge), "", alice); 175 | } 176 | 177 | function testRevokeFails() external { 178 | bytes32 uid = _attest(address(badge), "", alice); 179 | badge.baseFail(); 180 | vm.expectRevert(EAS.InvalidRevocation.selector); 181 | _revoke(uid); 182 | } 183 | } 184 | 185 | contract TestContractNonRevocable is TestContractBase, ScrollBadgeNonRevocable { 186 | constructor(address resolver_) TestContractBase(resolver_) {} 187 | 188 | function onIssueBadge(Attestation calldata attestation) 189 | internal 190 | virtual 191 | override (TestContractBase, ScrollBadgeNonRevocable) 192 | returns (bool) 193 | { 194 | return super.onIssueBadge(attestation); 195 | } 196 | 197 | function onRevokeBadge(Attestation calldata attestation) 198 | internal 199 | virtual 200 | override (ScrollBadge, TestContractBase) 201 | returns (bool) 202 | { 203 | return super.onRevokeBadge(attestation); 204 | } 205 | } 206 | 207 | contract ScrollBadgeNonRevocableInheritanceChainTest is ScrollBadgeTestBase { 208 | TestContractNonRevocable internal badge; 209 | 210 | function setUp() public virtual override { 211 | super.setUp(); 212 | 213 | badge = new TestContractNonRevocable(address(resolver)); 214 | resolver.toggleBadge(address(badge), true); 215 | } 216 | 217 | function testAttestFails() external { 218 | bytes memory attestationData = abi.encode(badge, ""); 219 | 220 | AttestationRequestData memory _attData = AttestationRequestData({ 221 | recipient: alice, 222 | expirationTime: NO_EXPIRATION_TIME, 223 | revocable: false, 224 | refUID: EMPTY_UID, 225 | data: attestationData, 226 | value: 0 227 | }); 228 | 229 | AttestationRequest memory _req = AttestationRequest({schema: schema, data: _attData}); 230 | 231 | badge.baseFail(); 232 | vm.expectRevert(EAS.InvalidAttestation.selector); 233 | eas.attest(_req); 234 | } 235 | } 236 | 237 | contract TestContractSBT is TestContractBase, ScrollBadgeSBT { 238 | constructor(address resolver_) TestContractBase(resolver_) ScrollBadgeSBT("name", "symbol") {} 239 | 240 | function onIssueBadge(Attestation calldata attestation) 241 | internal 242 | virtual 243 | override (TestContractBase, ScrollBadgeSBT) 244 | returns (bool) 245 | { 246 | return super.onIssueBadge(attestation); 247 | } 248 | 249 | function onRevokeBadge(Attestation calldata attestation) 250 | internal 251 | virtual 252 | override (TestContractBase, ScrollBadgeSBT) 253 | returns (bool) 254 | { 255 | return super.onRevokeBadge(attestation); 256 | } 257 | } 258 | 259 | contract ScrollBadgeSBTInheritanceChainTest is ScrollBadgeTestBase { 260 | TestContractSBT internal badge; 261 | 262 | function setUp() public virtual override { 263 | super.setUp(); 264 | 265 | badge = new TestContractSBT(address(resolver)); 266 | resolver.toggleBadge(address(badge), true); 267 | } 268 | 269 | function testAttestFails() external { 270 | badge.baseFail(); 271 | vm.expectRevert(EAS.InvalidAttestation.selector); 272 | _attest(address(badge), "", alice); 273 | } 274 | 275 | function testRevokeFails() external { 276 | bytes32 uid = _attest(address(badge), "", alice); 277 | badge.baseFail(); 278 | vm.expectRevert(EAS.InvalidRevocation.selector); 279 | _revoke(uid); 280 | } 281 | } 282 | 283 | contract TestContractSelfAttest is TestContractBase, ScrollBadgeSelfAttest { 284 | constructor(address resolver_) TestContractBase(resolver_) {} 285 | 286 | function onIssueBadge(Attestation calldata attestation) 287 | internal 288 | virtual 289 | override (TestContractBase, ScrollBadgeSelfAttest) 290 | returns (bool) 291 | { 292 | return super.onIssueBadge(attestation); 293 | } 294 | 295 | function onRevokeBadge(Attestation calldata attestation) 296 | internal 297 | virtual 298 | override (TestContractBase, ScrollBadgeSelfAttest) 299 | returns (bool) 300 | { 301 | return super.onRevokeBadge(attestation); 302 | } 303 | } 304 | 305 | contract ScrollBadgeSelfAttestInheritanceChainTest is ScrollBadgeTestBase { 306 | TestContractSelfAttest internal badge; 307 | 308 | function setUp() public virtual override { 309 | super.setUp(); 310 | 311 | badge = new TestContractSelfAttest(address(resolver)); 312 | resolver.toggleBadge(address(badge), true); 313 | } 314 | 315 | function testAttestFails() external { 316 | badge.baseFail(); 317 | vm.expectRevert(EAS.InvalidAttestation.selector); 318 | _attest(address(badge), "", address(this)); 319 | } 320 | 321 | function testRevokeFails() external { 322 | bytes32 uid = _attest(address(badge), "", address(this)); 323 | badge.baseFail(); 324 | vm.expectRevert(EAS.InvalidRevocation.selector); 325 | _revoke(uid); 326 | } 327 | } 328 | 329 | contract TestContractSingleton is TestContractBase, ScrollBadgeSingleton { 330 | constructor(address resolver_) TestContractBase(resolver_) {} 331 | 332 | function onIssueBadge(Attestation calldata attestation) 333 | internal 334 | virtual 335 | override (TestContractBase, ScrollBadgeSingleton) 336 | returns (bool) 337 | { 338 | return super.onIssueBadge(attestation); 339 | } 340 | 341 | function onRevokeBadge(Attestation calldata attestation) 342 | internal 343 | virtual 344 | override (TestContractBase, ScrollBadgeSingleton) 345 | returns (bool) 346 | { 347 | return super.onRevokeBadge(attestation); 348 | } 349 | } 350 | 351 | contract ScrollBadgeSingletonInheritanceChainTest is ScrollBadgeTestBase { 352 | TestContractSingleton internal badge; 353 | 354 | function setUp() public virtual override { 355 | super.setUp(); 356 | 357 | badge = new TestContractSingleton(address(resolver)); 358 | resolver.toggleBadge(address(badge), true); 359 | } 360 | 361 | function testAttestFails() external { 362 | badge.baseFail(); 363 | vm.expectRevert(EAS.InvalidAttestation.selector); 364 | _attest(address(badge), "", alice); 365 | } 366 | 367 | function testRevokeFails() external { 368 | bytes32 uid = _attest(address(badge), "", alice); 369 | badge.baseFail(); 370 | vm.expectRevert(EAS.InvalidRevocation.selector); 371 | _revoke(uid); 372 | } 373 | } 374 | --------------------------------------------------------------------------------