From 5412aeaf86fd78d70318d9ecac57aef3db0684e6 Mon Sep 17 00:00:00 2001 From: Xi Lin Date: Tue, 26 Nov 2024 15:33:07 +0800 Subject: [PATCH] feat: add SCRHoldingBadge (#57) * feat: add SCRHoldingBadge * naming and missing override * feat: unit tests for SCRHoldingBadge --- src/Common.sol | 4 + src/badge/examples/SCRHoldingBadge.sol | 150 ++++++++++ .../extensions/ScrollBadgeDefaultURI.sol | 2 +- src/interfaces/IScrollBadgeResolver.sol | 6 +- .../IScrollSelfAttestationBadge.sol | 17 ++ src/resolver/ScrollBadgeResolver.sol | 38 ++- test/SCRHoldingBadge.t.sol | 266 ++++++++++++++++++ 7 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 src/badge/examples/SCRHoldingBadge.sol create mode 100644 src/interfaces/IScrollSelfAttestationBadge.sol create mode 100644 test/SCRHoldingBadge.t.sol diff --git a/src/Common.sol b/src/Common.sol index a6c5d05..19f8238 100644 --- a/src/Common.sol +++ b/src/Common.sol @@ -9,3 +9,7 @@ string constant SCROLL_BADGE_SCHEMA = "address badge, bytes payload"; function decodeBadgeData(bytes memory data) pure returns (address, bytes memory) { return abi.decode(data, (address, bytes)); } + +function encodeBadgeData(address badge, bytes memory payload) pure returns (bytes memory) { + return abi.encode(badge, payload); +} diff --git a/src/badge/examples/SCRHoldingBadge.sol b/src/badge/examples/SCRHoldingBadge.sol new file mode 100644 index 0000000..fc6c4e3 --- /dev/null +++ b/src/badge/examples/SCRHoldingBadge.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.19; + +import {Attestation} from "@eas/contracts/IEAS.sol"; +import {NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +import {IScrollBadgeResolver} from "../../interfaces/IScrollBadgeResolver.sol"; +import {IScrollBadge, IScrollSelfAttestationBadge} from "../../interfaces/IScrollSelfAttestationBadge.sol"; +import {encodeBadgeData} from "../../Common.sol"; +import {ScrollBadge} from "../ScrollBadge.sol"; +import {ScrollBadgeCustomPayload} from "../extensions/ScrollBadgeCustomPayload.sol"; +import {ScrollBadgeDefaultURI} from "../extensions/ScrollBadgeDefaultURI.sol"; + +string constant SCR_HOLDING_BADGE_SCHEMA = "uint256 level"; + +function decodePayloadData(bytes memory data) pure returns (uint256) { + return abi.decode(data, (uint256)); +} + +/// @title SCRHoldingBadge +/// @notice A badge that represents user's SCR holding amount. +contract SCRHoldingBadge is ScrollBadgeCustomPayload, ScrollBadgeDefaultURI, Ownable, IScrollSelfAttestationBadge { + uint256 private constant LEVEL_ONE_SCR_AMOUNT = 1 ether; + uint256 private constant LEVEL_TWO_SCR_AMOUNT = 10 ether; + uint256 private constant LEVEL_THREE_SCR_AMOUNT = 100 ether; + uint256 private constant LEVEL_FOUR_SCR_AMOUNT = 1000 ether; + uint256 private constant LEVEL_FIVE_SCR_AMOUNT = 10_000 ether; + uint256 private constant LEVEL_SIX_SCR_AMOUNT = 100_000 ether; + + /// @notice The address of SCR token. + address public immutable scr; + + constructor(address resolver_, string memory baseTokenURI_, address scr_) + ScrollBadge(resolver_) + ScrollBadgeDefaultURI(baseTokenURI_) + { + scr = scr_; + } + + /// @notice Update the base token URI. + /// @param baseTokenURI_ The new base token URI. + function updateBaseTokenURI(string memory baseTokenURI_) external onlyOwner { + defaultBadgeURI = baseTokenURI_; + } + + /// @inheritdoc ScrollBadge + function onIssueBadge(Attestation calldata) + internal + virtual + override (ScrollBadge, ScrollBadgeCustomPayload) + returns (bool) + { + return false; + } + + /// @inheritdoc ScrollBadge + function onRevokeBadge(Attestation calldata) + internal + virtual + override (ScrollBadge, ScrollBadgeCustomPayload) + returns (bool) + { + return false; + } + + /// @inheritdoc ScrollBadge + function badgeTokenURI(bytes32 uid) + public + view + override (IScrollBadge, ScrollBadge, ScrollBadgeDefaultURI) + returns (string memory) + { + return ScrollBadgeDefaultURI.badgeTokenURI(uid); + } + + /// @inheritdoc IScrollBadge + function hasBadge(address user) public view virtual override (IScrollBadge, ScrollBadge) returns (bool) { + uint256 balance = IERC20(scr).balanceOf(user); + return balance >= LEVEL_ONE_SCR_AMOUNT; + } + + /// @inheritdoc ScrollBadgeDefaultURI + function getBadgeTokenURI(bytes32 uid) internal view override returns (string memory) { + Attestation memory attestation = getAndValidateBadge(uid); + bytes memory payload = getPayload(attestation); + uint256 level = decodePayloadData(payload); + + return string(abi.encodePacked(defaultBadgeURI, Strings.toString(level), ".json")); + } + + /// @inheritdoc ScrollBadgeCustomPayload + function getSchema() public pure override returns (string memory) { + return SCR_HOLDING_BADGE_SCHEMA; + } + + /// @inheritdoc IScrollSelfAttestationBadge + function getBadgeId() external pure returns (uint256) { + return 0; + } + + /// @inheritdoc IScrollSelfAttestationBadge + /// + /// @dev The uid encoding should be + /// ```text + /// [ address | badge id | customized data ] + /// [ 160 bits | 32 bits | 64 bits ] + /// [LSB MSB] + /// ``` + /// The *badge id* and the *customized data* should both be zero. + function getAttestation(bytes32 uid) external view override returns (Attestation memory attestation) { + // invalid uid, return empty badge + if ((uint256(uid) >> 160) > 0) return attestation; + + // extract badge recipient from uid + address recipient; + assembly { + recipient := and(uid, 0xffffffffffffffffffffffffffffffffffffffff) + } + + // compute payload + uint256 level; + uint256 balance = IERC20(scr).balanceOf(recipient); + // not hold enough SCR, return empty badge + if (balance < LEVEL_ONE_SCR_AMOUNT) return attestation; + else if (balance < LEVEL_TWO_SCR_AMOUNT) level = 1; + else if (balance < LEVEL_THREE_SCR_AMOUNT) level = 2; + else if (balance < LEVEL_FOUR_SCR_AMOUNT) level = 3; + else if (balance < LEVEL_FIVE_SCR_AMOUNT) level = 4; + else if (balance < LEVEL_SIX_SCR_AMOUNT) level = 5; + else level = 6; + bytes memory payload = abi.encode(level); + + // fill data in Attestation + attestation.uid = uid; + attestation.schema = IScrollBadgeResolver(resolver).schema(); + attestation.time = uint64(block.timestamp); + attestation.expirationTime = NO_EXPIRATION_TIME; + attestation.refUID = bytes32(0); + attestation.recipient = recipient; + attestation.attester = address(this); + attestation.revocable = false; + attestation.data = encodeBadgeData(address(this), payload); + + return attestation; + } +} diff --git a/src/badge/extensions/ScrollBadgeDefaultURI.sol b/src/badge/extensions/ScrollBadgeDefaultURI.sol index c6a2c3b..41a4ec8 100644 --- a/src/badge/extensions/ScrollBadgeDefaultURI.sol +++ b/src/badge/extensions/ScrollBadgeDefaultURI.sol @@ -14,7 +14,7 @@ abstract contract ScrollBadgeDefaultURI is ScrollBadge { } /// @inheritdoc ScrollBadge - function badgeTokenURI(bytes32 uid) public view override returns (string memory) { + function badgeTokenURI(bytes32 uid) public view virtual override returns (string memory) { if (uid == bytes32(0)) { return defaultBadgeURI; } diff --git a/src/interfaces/IScrollBadgeResolver.sol b/src/interfaces/IScrollBadgeResolver.sol index b87164a..0dd23e4 100644 --- a/src/interfaces/IScrollBadgeResolver.sol +++ b/src/interfaces/IScrollBadgeResolver.sol @@ -32,15 +32,15 @@ interface IScrollBadgeResolver { /// @notice Return the Scroll badge attestation schema. /// @return The GUID of the Scroll badge attestation schema. - function schema() external returns (bytes32); + function schema() external view returns (bytes32); /// @notice The profile registry contract. /// @return The address of the profile registry. - function registry() external returns (address); + function registry() external view returns (address); /// @notice The global EAS contract. /// @return The address of the global EAS contract. - function eas() external returns (address); + function eas() external view returns (address); /// @notice Validate and return a Scroll badge attestation. /// @param uid The attestation UID. diff --git a/src/interfaces/IScrollSelfAttestationBadge.sol b/src/interfaces/IScrollSelfAttestationBadge.sol new file mode 100644 index 0000000..74123d1 --- /dev/null +++ b/src/interfaces/IScrollSelfAttestationBadge.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.19; + +import {Attestation} from "@eas/contracts/IEAS.sol"; + +import {IScrollBadge} from "./IScrollBadge.sol"; + +interface IScrollSelfAttestationBadge is IScrollBadge { + /// @notice Return the unique id of this badge. + function getBadgeId() external view returns (uint256); + + /// @notice Returns an existing attestation by UID. + /// @param uid The UID of the attestation to retrieve. + /// @return The attestation data members. + function getAttestation(bytes32 uid) external view returns (Attestation memory); +} diff --git a/src/resolver/ScrollBadgeResolver.sol b/src/resolver/ScrollBadgeResolver.sol index e80306b..9d8a5bd 100644 --- a/src/resolver/ScrollBadgeResolver.sol +++ b/src/resolver/ScrollBadgeResolver.sol @@ -12,6 +12,7 @@ import {IProfile} from "../interfaces/IProfile.sol"; import {IProfileRegistry} from "../interfaces/IProfileRegistry.sol"; import {IScrollBadge} from "../interfaces/IScrollBadge.sol"; import {IScrollBadgeResolver} from "../interfaces/IScrollBadgeResolver.sol"; +import {IScrollSelfAttestationBadge} from "../interfaces/IScrollSelfAttestationBadge.sol"; import {SCROLL_BADGE_SCHEMA, decodeBadgeData} from "../Common.sol"; import {ScrollBadgeResolverWhitelist} from "./ScrollBadgeResolverWhitelist.sol"; @@ -49,8 +50,19 @@ contract ScrollBadgeResolver is IScrollBadgeResolver, SchemaResolver, ScrollBadg /// @inheritdoc IScrollBadgeResolver bytes32 public schema; + /// @notice The list of self attested badges, mapping from badge id to badge address. + /// @dev This is a list of badges with special needs which EAS cannot satisfy, such as + /// auto attest/revoke badge based on certain token holding amount. + /// The uid for the badge is customized in the following way: + /// ```text + /// [ address | badge id | customized data ] + /// [ 160 bits | 32 bits | 64 bits ] + /// [LSB MSB] + /// ``` + mapping(uint256 => address) public selfAttestedBadges; + // Storage slots reserved for future upgrades. - uint256[49] private __gap; + uint256[48] private __gap; /** * @@ -165,8 +177,19 @@ contract ScrollBadgeResolver is IScrollBadgeResolver, SchemaResolver, ScrollBadg function getAndValidateBadge(bytes32 uid) external view returns (Attestation memory) { Attestation memory attestation = _eas.getAttestation(uid); + // if we cannot find the badge in EAS, try self attestation if (attestation.uid == EMPTY_UID) { - revert AttestationNotFound(uid); + // extract badge address from uid and do self attestation + uint256 badgeId = uint256(uid) >> 160 & 0xffffffff; + address badgeAddr = selfAttestedBadges[badgeId]; + if (badgeAddr != address(0)) { + attestation = IScrollSelfAttestationBadge(badgeAddr).getAttestation(uid); + } + if (attestation.uid == EMPTY_UID) { + revert AttestationNotFound(uid); + } else { + return attestation; + } } if (attestation.schema != schema) { @@ -184,6 +207,17 @@ contract ScrollBadgeResolver is IScrollBadgeResolver, SchemaResolver, ScrollBadg return attestation; } + /** + * + * Restricted Functions * + * + */ + + /// @notice Update the address of a self attested badge. + function updateSelfAttestedBadge(uint256 badgeId, address badgeAddress) external onlyOwner { + selfAttestedBadges[badgeId] = badgeAddress; + } + /** * * Internal Functions * diff --git a/test/SCRHoldingBadge.t.sol b/test/SCRHoldingBadge.t.sol new file mode 100644 index 0000000..2c31227 --- /dev/null +++ b/test/SCRHoldingBadge.t.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {MockERC20} from "forge-std/mocks/MockERC20.sol"; + +import {EAS} from "@eas/contracts/EAS.sol"; +import {EMPTY_UID, NO_EXPIRATION_TIME} from "@eas/contracts/Common.sol"; +import {SchemaRegistry, ISchemaRegistry} from "@eas/contracts/SchemaRegistry.sol"; +import {IEAS, Attestation, AttestationRequest, AttestationRequestData, RevocationRequest, RevocationRequestData} from "@eas/contracts/IEAS.sol"; + +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {EmptyContract} from "../src/misc/EmptyContract.sol"; +import {Profile} from "../src/profile/Profile.sol"; +import {ProfileRegistry} from "../src/profile/ProfileRegistry.sol"; +import {ScrollBadge} from "../src/badge/ScrollBadge.sol"; +import {ScrollBadgeResolver} from "../src/resolver/ScrollBadgeResolver.sol"; +import {SCRHoldingBadge} from "../src/badge/examples/SCRHoldingBadge.sol"; + +import {encodeBadgeData} from "../src/Common.sol"; +import {AttestationNotFound} from "../src/Errors.sol"; + +contract Token is MockERC20 { + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract SCRHoldingBadgeTest is Test { + address private constant TREASURY_ADDRESS = 0x1000000000000000000000000000000000000000; + + address private constant PROXY_ADMIN_ADDRESS = 0x2000000000000000000000000000000000000000; + + ISchemaRegistry private schemaRegistry; + IEAS private eas; + ScrollBadgeResolver private resolver; + SCRHoldingBadge private badge; + Token private token; + + Profile private profileImpl; + ProfileRegistry private profileRegistry; + Profile private profile; + + receive() external payable {} + + function setUp() public { + schemaRegistry = new SchemaRegistry(); + eas = new EAS(schemaRegistry); + address profileRegistryProxy = address( + new TransparentUpgradeableProxy(address(new EmptyContract()), PROXY_ADMIN_ADDRESS, "") + ); + + address resolverImpl = address(new ScrollBadgeResolver(address(eas), profileRegistryProxy)); + address resolverProxy = address(new TransparentUpgradeableProxy(resolverImpl, PROXY_ADMIN_ADDRESS, "")); + resolver = ScrollBadgeResolver(payable(resolverProxy)); + resolver.initialize(); + + token = new Token(); + badge = new SCRHoldingBadge(address(resolver), "xx", address(token)); + resolver.updateSelfAttestedBadge(0, address(badge)); + + profileImpl = new Profile(address(resolver)); + ProfileRegistry profileRegistryImpl = new ProfileRegistry(); + vm.prank(PROXY_ADMIN_ADDRESS); + ITransparentUpgradeableProxy(profileRegistryProxy).upgradeTo(address(profileRegistryImpl)); + profileRegistry = ProfileRegistry(profileRegistryProxy); + profileRegistry.initialize(TREASURY_ADDRESS, TREASURY_ADDRESS, address(profileImpl)); + profile = Profile(profileRegistry.mint{value: 0.001 ether}("xxxxx", new bytes(0))); + } + + function testInitialize() external view { + // from ScrollBadge + assertEq(badge.resolver(), address(resolver)); + + // from ScrollBadgeCustomPayload + assertEq(badge.getSchema(), "uint256 level"); + + // from ScrollBadgeDefaultURI + assertEq(badge.defaultBadgeURI(), "xx"); + assertEq(badge.badgeTokenURI(0), "xx"); + + // from SCRHoldingBadge + assertEq(badge.scr(), address(token)); + assertEq(badge.getBadgeId(), 0); + + // in ScrollBadgeResolver + assertEq(resolver.selfAttestedBadges(0), address(badge)); + } + + function testIssueBadge(Attestation calldata attestation) external { + vm.prank(address(resolver)); + assertEq(false, badge.issueBadge(attestation)); + } + + function testRevokeBadge(Attestation calldata attestation) external { + vm.prank(address(resolver)); + assertEq(false, badge.revokeBadge(attestation)); + } + + function testGetAndValidateBadge() external { + bytes32 uid; + // badge id nonzero + assembly { + uid := 0 + uid := or(uid, shl(1, 160)) + } + vm.expectRevert(abi.encodePacked(AttestationNotFound.selector, uid)); + badge.getAndValidateBadge(uid); + + // customized data nonzero + assembly { + uid := 0 + uid := or(uid, shl(1, 192)) + } + vm.expectRevert(abi.encodePacked(AttestationNotFound.selector, uid)); + badge.getAndValidateBadge(uid); + + // no scr + assembly { + uid := address() + } + token.mint(address(this), 1 ether - 1); + vm.expectRevert(abi.encodePacked(AttestationNotFound.selector, uid)); + badge.getAndValidateBadge(uid); + + // succeed + assembly { + uid := address() + } + token.mint(address(this), 1 ether); + Attestation memory attestation = badge.getAndValidateBadge(uid); + assertEq(attestation.uid, uid); + assertEq(attestation.schema, resolver.schema()); + assertEq(attestation.time, block.timestamp); + assertEq(attestation.expirationTime, 0); + assertEq(attestation.refUID, bytes32(0)); + assertEq(attestation.recipient, address(this)); + assertEq(attestation.attester, address(badge)); + assertEq(attestation.revocable, false); + assertEq(attestation.data, encodeBadgeData(address(badge), abi.encode(uint256(1)))); + } + + function testBadgeTokenURI(address user, uint256 amount) external { + vm.assume(amount >= 1 ether); + vm.assume(user != address(0)); + + uint256 level; + if (amount >= 1 ether) level = 1; + if (amount >= 10 ether) level = 2; + if (amount >= 100 ether) level = 3; + if (amount >= 1000 ether) level = 4; + if (amount >= 10000 ether) level = 5; + if (amount >= 100000 ether) level = 6; + + token.mint(user, amount); + bytes32 uid; + assembly { + uid := user + } + assertEq(badge.badgeTokenURI(uid), string(abi.encodePacked("xx", Strings.toString(level), ".json"))); + } + + function testHasBadge(address user, uint256 amount) external { + vm.assume(user != address(0)); + + token.mint(user, amount); + assertEq(badge.hasBadge(user), amount >= 1 ether); + } + + function testGetAttestationInvalidUID(address user, uint96 base) external view { + vm.assume(base > 0); + bytes32 uid; + assembly { + uid := or(user, shl(160, base)) + } + Attestation memory attestation = badge.getAttestation(uid); + assertEq(attestation.uid, bytes32(0)); + assertEq(attestation.schema, ""); + assertEq(attestation.time, 0); + assertEq(attestation.expirationTime, 0); + assertEq(attestation.refUID, bytes32(0)); + assertEq(attestation.recipient, address(0)); + assertEq(attestation.attester, address(0)); + assertEq(attestation.revocable, false); + assertEq(attestation.data, ""); + } + + function testGetAttestationNoSCR(address user, uint256 amount) external { + amount = bound(amount, 0, 1 ether - 1); + token.mint(user, amount); + bytes32 uid; + assembly { + uid := user + } + Attestation memory attestation = badge.getAttestation(uid); + _validateAttestation(attestation, user); + } + + function testGetAttestation(address user, uint256 amount, uint256 amount2) external { + vm.assume(amount >= 1 ether); + vm.assume(user != address(0)); + amount2 = bound(amount2, 0, amount); + + uint256 level; + if (amount >= 1 ether) level = 1; + if (amount >= 10 ether) level = 2; + if (amount >= 100 ether) level = 3; + if (amount >= 1000 ether) level = 4; + if (amount >= 10000 ether) level = 5; + if (amount >= 100000 ether) level = 6; + + token.mint(user, amount); + bytes32 uid; + assembly { + uid := user + } + Attestation memory attestation = badge.getAttestation(uid); + _validateAttestation(attestation, user); + + // transfer + vm.prank(user); + token.transfer(address(this), amount2); + attestation = badge.getAttestation(uid); + _validateAttestation(attestation, user); + } + + function _validateAttestation(Attestation memory attestation, address user) internal view { + uint256 amount = token.balanceOf(user); + if (amount < 1 ether) { + assertEq(attestation.uid, bytes32(0)); + assertEq(attestation.schema, ""); + assertEq(attestation.time, 0); + assertEq(attestation.expirationTime, 0); + assertEq(attestation.refUID, bytes32(0)); + assertEq(attestation.recipient, address(0)); + assertEq(attestation.attester, address(0)); + assertEq(attestation.revocable, false); + assertEq(attestation.data, ""); + } else { + bytes32 uid; + assembly { + uid := user + } + uint256 level; + if (amount >= 1 ether) level = 1; + if (amount >= 10 ether) level = 2; + if (amount >= 100 ether) level = 3; + if (amount >= 1000 ether) level = 4; + if (amount >= 10000 ether) level = 5; + if (amount >= 100000 ether) level = 6; + assertEq(attestation.uid, uid); + assertEq(attestation.schema, resolver.schema()); + assertEq(attestation.time, block.timestamp); + assertEq(attestation.expirationTime, 0); + assertEq(attestation.refUID, bytes32(0)); + assertEq(attestation.recipient, user); + assertEq(attestation.attester, address(badge)); + assertEq(attestation.revocable, false); + assertEq(attestation.data, encodeBadgeData(address(badge), abi.encode(level))); + } + } +}