diff --git a/contracts/passport/SmartBuilderScore.sol b/contracts/passport/SmartBuilderScore.sol index f5c6cff4..876d7ede 100644 --- a/contracts/passport/SmartBuilderScore.sol +++ b/contracts/passport/SmartBuilderScore.sol @@ -6,6 +6,7 @@ import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import "./PassportBuilderScore.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; +// Deprecated in favor of TalentBuilderScorer contract SmartBuilderScore is Ownable { using ECDSA for bytes32; diff --git a/contracts/passport/TalentBuilderScore.sol b/contracts/passport/TalentBuilderScore.sol new file mode 100644 index 00000000..aa6a5597 --- /dev/null +++ b/contracts/passport/TalentBuilderScore.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import "./PassportBuilderScore.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract TalentBuilderScore is Ownable { + using ECDSA for bytes32; + + address public trustedSigner; + address public feeReceiver; + PassportBuilderScore public passportBuilderScore; + PassportRegistry public passportRegistry; + uint256 public cost = 0.0001 ether; + + event BuilderScoreSet(address indexed user, uint256 score, uint256 talentId); + + bool public enabled; + + constructor( + address _trustedSigner, + address _passportBuilderScoreAddress, + address _passportRegistryAddress, + address _feeReceiver + ) Ownable(_trustedSigner) { + trustedSigner = _trustedSigner; + passportBuilderScore = PassportBuilderScore(_passportBuilderScoreAddress); + passportRegistry = PassportRegistry(_passportRegistryAddress); + feeReceiver = _feeReceiver; + enabled = true; + } + + /** + * @notice Changes the owner of passport registry. + * @param _newOwner The new owner of passport registry. + * @dev Can only be called by the owner. + */ + function setPassportRegistryOwner(address _newOwner) public onlyOwner { + passportRegistry.transferOwnership(_newOwner); + } + + /** + * @notice Enables or disables the SmartBuilderScore contract. + * @param _enabled Whether the SmartBuilderScore contract should be enabled. + * @dev Can only be called by the owner. + */ + function setEnabled(bool _enabled) public onlyOwner { + enabled = _enabled; + } + + /** + * @notice Disables the SmartBuilderScore contract. + * @dev Can only be called by the owner. + */ + function setDisabled() public onlyOwner { + enabled = false; + } + + /** + * @notice Sets the cost of adding a score. + * @param _cost The cost of adding a score. + * @dev Can only be called by the owner. + */ + function setCost(uint256 _cost) public onlyOwner { + cost = _cost; + } + + /** + * @notice Updates the fee receiver address. + * @param _feeReceiver The new fee receiver address. + * @dev Can only be called by the owner. + */ + function updateReceiver(address _feeReceiver) public onlyOwner { + feeReceiver = _feeReceiver; + } + + /** + * @notice Creates an attestation if the provided number is signed by the trusted signer. + * @param score The number to be attested. + * @param talentId The number of the talent profile to receive the attestation. + * @param wallet The wallet to receive the attestation. + * @param signature The signature of the trusted signer. + */ + function addScore(uint256 score, uint256 talentId, address wallet, bytes memory signature) public payable { + require(enabled, "Setting the Builder Score is disabled for this contract"); + // Ensure the caller has paid the required fee + require(msg.value >= cost, "Insufficient payment"); + // Hash the number + bytes32 numberHash = keccak256(abi.encodePacked(score, talentId, wallet)); + + // Recover the address that signed the hash + address signer = MessageHashUtils.toEthSignedMessageHash(numberHash).recover(signature); + + // Ensure the signer is the trusted signer + require(signer == trustedSigner, "Invalid signature"); + + // Transfer fee to fee receiver + payable(feeReceiver).transfer(msg.value); + + // Create passport if it does not exist + if(passportRegistry.idPassport(talentId) == address(0)) { + passportRegistry.adminCreate("talent_builder_score", wallet, talentId); + } + + // Emit event + require(passportBuilderScore.setScore(talentId, score), "Failed to set score"); + emit BuilderScoreSet(wallet, score, talentId); + } +} diff --git a/scripts/passport/deployScorer.ts b/scripts/passport/deployScorer.ts deleted file mode 100644 index 7dcd9886..00000000 --- a/scripts/passport/deployScorer.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ethers, network } from "hardhat"; -import { deployPassportBuilderScore, deploySmartBuilderScore } from "../shared"; - -const PASSPORT_MAINNET = "0xb477A9BD2547ad61f4Ac22113172Dd909E5B2331"; -const PASSPORT_TESTNET = "0xa600b3356c1440B6D6e57b0B7862dC3dFB66bc43"; - -const FEE_RECEIVER_MAINNET = "0xC925bD0E839E8e22A7DDEbe7f4C21b187deeC358"; -const FEE_RECEIVER_TESTNET = "0x08BC8a92e5C99755C675A21BC4FcfFb59E0A9508"; - -async function main() { - console.log(`Deploying passport builder score at ${network.name}`); - - const [admin] = await ethers.getSigners(); - - console.log(`Admin will be ${admin.address}`); - - const builderScore = await deployPassportBuilderScore(PASSPORT_TESTNET, admin.address); - - console.log(`Scorer Address: ${builderScore.address}`); - console.log(`Scorer owner: ${await builderScore.owner()}`); - - const smartBuilderScore = await deploySmartBuilderScore( - admin.address, - PASSPORT_TESTNET, - FEE_RECEIVER_TESTNET, - builderScore.address - ); - - console.log(`Smart Builder Score Address: ${smartBuilderScore.address}`); - - console.log("Adding trusted signer"); - await builderScore.addTrustedSigner(smartBuilderScore.address); - - console.log("Done"); -} - -main() - .then(() => process.exit(0)) - .catch((error) => { - console.error(error); - process.exit(1); - }); diff --git a/scripts/passport/deployTalentBuilderScore.ts b/scripts/passport/deployTalentBuilderScore.ts new file mode 100644 index 00000000..2ff07655 --- /dev/null +++ b/scripts/passport/deployTalentBuilderScore.ts @@ -0,0 +1,63 @@ +import { ethers, network } from "hardhat"; + +import { deployTalentBuilderScore } from "../shared"; +import { PassportBuilderScore, PassportRegistry } from "../../test/shared/artifacts"; + +const PASSPORT_REGISTRY_ADDRESS_MAINNET = "0xb477A9BD2547ad61f4Ac22113172Dd909E5B2331"; +const PASSPORT_REGISTRY_ADDRESS_TESTNET = "0xa600b3356c1440B6D6e57b0B7862dC3dFB66bc43"; + +const PASSPORT_BUILDER_SCORE_MAINNET = "0xBBFeDA7c4d8d9Df752542b03CdD715F790B32D0B" +const PASSPORT_BUILDER_SCORE_TESTNET = "0x5f3aA689C4DCBAe505E6F6c8548DbD9b908bA71d" + +const FEE_RECEIVER_MAINNET = "0xC925bD0E839E8e22A7DDEbe7f4C21b187deeC358"; +const FEE_RECEIVER_TESTNET = "0x08BC8a92e5C99755C675A21BC4FcfFb59E0A9508"; + +async function main() { + console.log(`Deploying passport registry at ${network.name}`); + + const [admin] = await ethers.getSigners(); + + console.log(`Admin will be ${admin.address}`); + + const smartBuilderScore = await deployTalentBuilderScore( + admin.address, + PASSPORT_BUILDER_SCORE_TESTNET, + PASSPORT_REGISTRY_ADDRESS_TESTNET, + FEE_RECEIVER_TESTNET + ); + + console.log(`Smart Builder Score Address: ${smartBuilderScore.address}`); + + console.log("Adding trusted signer"); + + const passportBuilderScore = new ethers.Contract( + PASSPORT_BUILDER_SCORE_TESTNET, + PassportBuilderScore.abi, + admin + ); + await passportBuilderScore.addTrustedSigner(smartBuilderScore.address); + + const passportRegistry = new ethers.Contract( + PASSPORT_REGISTRY_ADDRESS_TESTNET, + PassportRegistry.abi, + admin + ); + + console.log("Transfering ownership"); + + // Set smart builder score as the owner of passportRegistry so it's the only contract that can create new passports onchain + await passportRegistry.transferOwnership(smartBuilderScore.address); + + const newOwner = await passportRegistry.owner(); + + console.log(`New owner: ${newOwner}`); + + console.log("Done"); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/passport/transferPassportBuilderScoreOwnership.ts b/scripts/passport/transferPassportBuilderScoreOwnership.ts new file mode 100644 index 00000000..aa70b7b3 --- /dev/null +++ b/scripts/passport/transferPassportBuilderScoreOwnership.ts @@ -0,0 +1,59 @@ +import hre from "hardhat"; + +import { getContract } from "viem"; +import { baseSepolia, base } from "viem/chains"; +import { privateKeyToSimpleSmartAccount } from "permissionless/accounts"; + +import dotenv from "dotenv"; + +dotenv.config(); + +import * as PassportBuilderScore from "../../artifacts/contracts/passport/PassportBuilderScore.sol/PassportBuilderScore.json"; + +const ENTRYPOINT = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"; +const PASSPORT_BUILDER_SCORE_ADDRESS = "0xBBFeDA7c4d8d9Df752542b03CdD715F790B32D0B" + +// Script to transfer ownership of passport buider score to a smart wallet +async function main() { + const [admin] = await hre.viem.getWalletClients(); + const publicClient = await hre.viem.getPublicClient(); + + if(!process.env.PRIVATE_KEY){ + console.error("Missing PK"); + return + } + const privateKey = `0x${process.env.PRIVATE_KEY}`; + + console.log("privateKey", privateKey); + const smartAccount = await privateKeyToSimpleSmartAccount(publicClient, { + privateKey, + entryPoint: ENTRYPOINT, // global entrypoint + factoryAddress: "0x9406Cc6185a346906296840746125a0E44976454", + }); + + console.log(`Owner SCA ${smartAccount.address}`); + + const passportBuilderScore = getContract({ + address: PASSPORT_BUILDER_SCORE_ADDRESS, + abi: PassportBuilderScore.abi, + client: { + public: publicClient, + wallet: admin, + }, + }); + + const tx = await passportBuilderScore.write.transferOwnership([smartAccount.address]); + + await publicClient.waitForTransactionReceipt({ hash: tx }); + + const owner = await passportBuilderScore.read.owner(); + + console.log(`New owner: ${owner}`); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/shared/index.ts b/scripts/shared/index.ts index 6f75a116..b1283426 100644 --- a/scripts/shared/index.ts +++ b/scripts/shared/index.ts @@ -6,7 +6,7 @@ import type { PassportBuilderScore, TalentCommunitySale, TalentTGEUnlock, - SmartBuilderScore, + TalentBuilderScore, PassportWalletRegistry, TalentTGEUnlockTimestamp, TalentVault, @@ -74,23 +74,23 @@ export async function deployPassportBuilderScore(registry: string, owner: string return deployedPassportBuilderScore as PassportBuilderScore; } -export async function deploySmartBuilderScore( +export async function deployTalentBuilderScore( owner: string, + passportBuilderScore: string, passportRegistry: string, feeReceiver: string, - passportBuilderScore: string -): Promise { - const smartBuilderScoreContract = await ethers.getContractFactory("SmartBuilderScore"); +): Promise { + const talentBuilderScoreContract = await ethers.getContractFactory("TalentBuilderScore"); - const deployedSmartBuilderScore = await smartBuilderScoreContract.deploy( + const deployedTalentBuilderScore = await talentBuilderScoreContract.deploy( owner, passportBuilderScore, passportRegistry, feeReceiver ); - await deployedSmartBuilderScore.deployed(); + await deployedTalentBuilderScore.deployed(); - return deployedSmartBuilderScore as SmartBuilderScore; + return deployedTalentBuilderScore as TalentBuilderScore; } export async function deployTalentCommunitySale( diff --git a/test/contracts/passport/TalentBuilderScore.ts b/test/contracts/passport/TalentBuilderScore.ts new file mode 100644 index 00000000..b91ce645 --- /dev/null +++ b/test/contracts/passport/TalentBuilderScore.ts @@ -0,0 +1,152 @@ +import chai from "chai"; +import { ethers, waffle } from "hardhat"; +import { solidity } from "ethereum-waffle"; + +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { TalentBuilderScore, PassportBuilderScore, PassportRegistry } from "../../../typechain-types"; +import { Artifacts } from "../../shared"; +import { findEvent } from "../../shared/utils"; +import { parseEther } from "ethers/lib/utils"; + +chai.use(solidity); + +const { expect } = chai; +const { deployContract } = waffle; + +describe("TalentBuilderScore", () => { + let admin: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + let feeCollector: SignerWithAddress; + let sourceCollector: SignerWithAddress; + + const existingOnchainId = 1005; + + let talentBuilderScore: TalentBuilderScore; + let passportBuilderScore: PassportBuilderScore; + let passportRegistry: PassportRegistry; + + beforeEach(async () => { + [admin, user1, user2, feeCollector, sourceCollector] = await ethers.getSigners(); + passportRegistry = (await deployContract(admin, Artifacts.PassportRegistry, [admin.address])) as PassportRegistry; + passportBuilderScore = (await deployContract(admin, Artifacts.PassportBuilderScore, [ + passportRegistry.address, + admin.address, + ])) as PassportBuilderScore; + talentBuilderScore = (await deployContract(admin, Artifacts.TalentBuilderScore, [ + admin.address, + passportBuilderScore.address, + passportRegistry.address, + feeCollector.address, + ])) as TalentBuilderScore; + + await passportRegistry.connect(admin).adminCreate("login.talentprotocol.com", user2.address, existingOnchainId) + // await passportRegistry.connect(admin).setGenerationMode(true, 1); // Enable sequential mode + await passportRegistry.connect(admin).transferOwnership(talentBuilderScore.address) + + await passportBuilderScore.connect(admin).addTrustedSigner(talentBuilderScore.address); + + }); + + describe("Deployment", () => { + it("allows changing the owner to a new address if called by the admin", async () => { + await talentBuilderScore.connect(admin).setPassportRegistryOwner(user1.address); + + const newOwner = await passportRegistry.owner(); + expect(newOwner).to.eq(user1.address); + }); + + it("does not allow changing the owner to a new address if not called by the admin", async () => { + const action = talentBuilderScore.connect(user1).setPassportRegistryOwner(user1.address); + + await expect(action).to.be.reverted; + }); + + it("allows setting a score onchain if the message came from the admin", async () => { + const score = 100; + const talentId = existingOnchainId; + const numberHash = ethers.utils.solidityKeccak256(["uint256", "uint256", "address"], [score, talentId, user1.address]); + // Sign the hash + const signature = await admin.signMessage(ethers.utils.arrayify(numberHash)); + + const tx = await talentBuilderScore + .connect(user1) + .addScore(score, talentId, user1.address, signature, { value: parseEther("0.005") }); + + const event = await findEvent(tx, "BuilderScoreSet"); + expect(event).to.exist; + expect(event?.args?.score).to.eq(score); + expect(event?.args?.talentId).to.eq(talentId); + expect(event?.args?.user).to.eq(user1.address); + + const scoreOnchain = await passportBuilderScore.getScore(talentId); + expect(scoreOnchain).to.eq(score); + }); + + it("allows setting a score onchain if the talentId is not yet created", async () => { + const score = 100; + const talentId = 10103 + const wallet = user1.address; + const numberHash = ethers.utils.solidityKeccak256(["uint256", "uint256", "address"], [score, talentId, wallet]); + // Sign the hash + const signature = await admin.signMessage(ethers.utils.arrayify(numberHash)); + + const tx = await talentBuilderScore + .connect(user1) + .addScore(score, talentId, wallet, signature, { value: parseEther("0.005") }); + + const event = await findEvent(tx, "BuilderScoreSet"); + expect(event).to.exist; + expect(event?.args?.score).to.eq(score); + expect(event?.args?.talentId).to.eq(talentId); + expect(event?.args?.user).to.eq(user1.address); + + const talentIdRegistered = await passportRegistry.passportId(wallet) + expect(talentIdRegistered).to.eq(talentId); + + const scoreOnchain = await passportBuilderScore.getScore(talentId); + expect(scoreOnchain).to.eq(score); + }); + + it("fails if setting a score onchain for a message that didnt come from the admin", async () => { + const score = 100; + const talentId = 1; + const numberHash = ethers.utils.solidityKeccak256(["uint256", "uint256", "address"], [score, talentId, user1.address]); + // Sign the hash + const signature = await user1.signMessage(ethers.utils.arrayify(numberHash)); + + const action = talentBuilderScore.addScore(score, talentId, user1.address, signature, { value: parseEther("0.005") }); + await expect(action).to.be.revertedWith("Invalid signature"); + }); + + it("fails if the user didn't transfer enough ETH", async () => { + const score = 100; + const talentId = 1; + const numberHash = ethers.utils.solidityKeccak256(["uint256", "uint256", "address"], [score, talentId, user1.address]); + // Sign the hash + const signature = await user1.signMessage(ethers.utils.arrayify(numberHash)); + + const action = talentBuilderScore.addScore(score, talentId, user1.address, signature, { value: parseEther("0.00001") }); + await expect(action).to.be.revertedWith("Insufficient payment"); + }); + + it("changes the balance by the full amount if the source doesn't exist", async () => { + const score = 100; + const talentId = existingOnchainId; + + const numberHash = ethers.utils.solidityKeccak256(["uint256", "uint256", "address"], [score, talentId, user1.address]); + // Sign the hash + const signature = await admin.signMessage(ethers.utils.arrayify(numberHash)); + + const balanceBefore = await feeCollector.getBalance(); + const balanceOfSourceBefore = await sourceCollector.getBalance(); + + await talentBuilderScore.connect(user1).addScore(score, talentId, user1.address, signature, { value: parseEther("0.005") }); + + const balanceAfter = await feeCollector.getBalance(); + const balanceOfSourceAfter = await sourceCollector.getBalance(); + expect(balanceAfter.sub(balanceBefore)).to.eq(parseEther("0.005")); + expect(balanceOfSourceAfter.sub(balanceOfSourceBefore)).to.eq(0); + }); + }); +}); diff --git a/test/shared/artifacts.ts b/test/shared/artifacts.ts index 308172cf..effe1374 100644 --- a/test/shared/artifacts.ts +++ b/test/shared/artifacts.ts @@ -6,6 +6,7 @@ import ERC20Mock from "../../artifacts/contracts/test/ERC20Mock.sol/ERC20Mock.js import TalentCommunitySale from "../../artifacts/contracts/talent/TalentCommunitySale.sol/TalentCommunitySale.json"; import USDTMock from "../../artifacts/contracts/test/ERC20Mock.sol/USDTMock.json"; import SmartBuilderScore from "../../artifacts/contracts/passport/SmartBuilderScore.sol/SmartBuilderScore.json"; +import TalentBuilderScore from "../../artifacts/contracts/passport/TalentBuilderScore.sol/TalentBuilderScore.json"; import PassportSources from "../../artifacts/contracts/passport/PassportSources.sol/PassportSources.json"; import TalentTGEUnlock from "../../artifacts/contracts/talent/TalentTGEUnlock.sol/TalentTGEUnlock.json"; import PassportWalletRegistry from "../../artifacts/contracts/passport/PassportWalletRegistry.sol/PassportWalletRegistry.json"; @@ -23,6 +24,7 @@ export { TalentCommunitySale, USDTMock, SmartBuilderScore, + TalentBuilderScore, PassportSources, TalentTGEUnlock, PassportWalletRegistry,