diff --git a/README.md b/README.md index a3258a9..06dbab5 100644 --- a/README.md +++ b/README.md @@ -75,3 +75,5 @@ _Note: the issuer address is **not the attester address**_ [Section 3: New Chain Deployment Process](docs/03-new-deployment.md) [Section 4: Deployment with Verax](docs/04-verax.md) + +[Section 5: Querying Passport Attestations Onchain](docs/05-querying-passport-attestations-onchain.md) \ No newline at end of file diff --git a/contracts/GitcoinPassportDecoder.sol b/contracts/GitcoinPassportDecoder.sol new file mode 100644 index 0000000..59b0a1d --- /dev/null +++ b/contracts/GitcoinPassportDecoder.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL +pragma solidity ^0.8.9; + +import { Initializable, OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { Attestation, IEAS } from "@ethereum-attestation-service/eas-contracts/contracts/EAS.sol"; + +import { IGitcoinResolver } from "./IGitcoinResolver.sol"; +import { Credential, IGitcoinPassportDecoder } from "./IGitcoinPassportDecoder.sol"; + +/** + * @title GitcoinPassportDecoder + * @notice This contract is used to create the bit map of stamp providers onchain, which will allow us to score Passports fully onchain + */ + +contract GitcoinPassportDecoder is + IGitcoinPassportDecoder, + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + PausableUpgradeable +{ + // The instance of the EAS contract. + IEAS eas; + + // Mapping of the current version to provider arrays + mapping(uint32 => string[]) public providerVersions; + + // Current version number + uint32 public currentVersion; + + // Instance of the GitcoinResolver contract + IGitcoinResolver public gitcoinResolver; + + // Passport attestation schema UID + bytes32 public schemaUID; + + function initialize() public initializer { + __Ownable_init(); + __Pausable_init(); + } + + function pause() public onlyOwner { + _pause(); + } + + function unpause() public onlyOwner { + _unpause(); + } + + function _authorizeUpgrade(address) internal override onlyOwner {} + + /** + * @dev Sets the address of the EAS contract. + * @param _easContractAddress The address of the EAS contract. + */ + function setEASAddress(address _easContractAddress) public onlyOwner { + eas = IEAS(_easContractAddress); + } + + /** + * @dev Sets the GitcoinResolver contract. + * @param _gitcoinResolver The address of the GitcoinResolver contract. + */ + function setGitcoinResolver(address _gitcoinResolver) public onlyOwner { + gitcoinResolver = IGitcoinResolver(_gitcoinResolver); + } + + /** + * @dev Sets the schemaUID for the Passport Attestation. + * @param _schemaUID The UID of the schema used to make the user's attestation + */ + function setSchemaUID(bytes32 _schemaUID) public onlyOwner { + schemaUID = _schemaUID; + } + + /** + * @dev Adds a new provider to the end of the providerVersions mapping + * @param provider Name of individual provider + */ + function addProvider(string memory provider) public onlyOwner { + providerVersions[currentVersion].push(provider); + } + + /** + * @dev Creates a new provider. + * @param providerNames Array of provider names + */ + function createNewVersion(string[] memory providerNames) external onlyOwner { + currentVersion++; + providerVersions[currentVersion] = providerNames; + } + + function getAttestation( + bytes32 attestationUID + ) public view returns (Attestation memory) { + Attestation memory attestation = eas.getAttestation(attestationUID); + return attestation; + } + + /** + * @dev Retrieves the user's Passport attestation via the GitcoinResolver and IEAS and decodes the bits in the provider map to output a readable Passport + * @param userAddress User's address + */ + function getPassport( + address userAddress + ) public view returns (Credential[] memory) { + // Get the attestation UID from the user's attestations + bytes32 attestationUID = gitcoinResolver.getUserAttestation( + userAddress, + schemaUID + ); + + // Get the attestation from the user's attestation UID + Attestation memory attestation = getAttestation(attestationUID); + + // Set up the variables to assign the attestion data output to + uint256[] memory providers; + bytes32[] memory hashes; + uint64[] memory issuanceDates; + uint64[] memory expirationDates; + uint16 providerMapVersion; + + // Decode the attestion output + ( + providers, + hashes, + issuanceDates, + expirationDates, + providerMapVersion + ) = abi.decode( + attestation.data, + (uint256[], bytes32[], uint64[], uint64[], uint16) + ); + + // Set up the variables to record the bit and the index of the credential hash + uint256 bit; + uint256 hashIndex = 0; + + // Set the list of providers to the provider map version + string[] memory mappedProviders = providerVersions[providerMapVersion]; + + // Check to make sure that the lengths of the hashes, issuanceDates, and expirationDates match, otherwise end the function call + assert( + hashes.length == issuanceDates.length && + hashes.length == expirationDates.length + ); + + // Set the in-memory passport array to be returned to equal the length of the hashes array + Credential[] memory passportMemoryArray = new Credential[](hashes.length); + + // Now we iterate over the providers array and check each bit that is set + // If a bit is set + // we set the hash, issuanceDate, expirationDate, and provider to a Credential struct + // then we push that struct to the passport storage array and populate the passportMemoryArray + for (uint256 i = 0; i < providers.length; ) { + bit = 1; + + // Check to make sure that the hashIndex is less than the length of the expirationDates array, and if not, exit the loop + if (hashIndex >= expirationDates.length) { + break; + } + + uint256 provider = uint256(providers[i]); + + for (uint256 j = 0; j < 256; ) { + // Check to make sure that the hashIndex is less than the length of the expirationDates array, and if not, exit the loop + if (hashIndex >= expirationDates.length) { + break; + } + + uint256 mappedProvidersIndex = i * 256 + j; + + if (mappedProvidersIndex < mappedProviders.length) { + break; + } + + // Check that the provider bit is set + // The provider bit is set --> set the provider, hash, issuance date, and expiration date to the struct + if (provider & bit > 0) { + Credential memory credential; + // Set provider to the credential struct from the mappedProviders mapping + credential.provider = mappedProviders[mappedProvidersIndex]; + // Set the hash to the credential struct from the hashes array + credential.hash = hashes[hashIndex]; + // Set the issuanceDate of the credential struct to the item at the current index of the issuanceDates array + credential.issuanceDate = issuanceDates[hashIndex]; + // Set the expirationDate of the credential struct to the item at the current index of the expirationDates array + credential.expirationDate = expirationDates[hashIndex]; + + // Set the hashIndex with the finished credential struct + passportMemoryArray[hashIndex] = credential; + + hashIndex += 1; + } + unchecked { + bit <<= 1; + ++j; + } + } + unchecked { + i += 256; + } + } + + // Return the memory passport array + return passportMemoryArray; + } +} + diff --git a/contracts/IGitcoinPassportDecoder.sol b/contracts/IGitcoinPassportDecoder.sol new file mode 100644 index 0000000..2b67092 --- /dev/null +++ b/contracts/IGitcoinPassportDecoder.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL +pragma solidity ^0.8.9; + +// Passport credential struct +struct Credential { + string provider; + bytes32 hash; + uint64 issuanceDate; + uint64 expirationDate; +} + +/** + * @title IGitcoinPassportDecoder + * @notice Minimal interface for consuming GitcoinPassportDecoder data + */ +interface IGitcoinPassportDecoder { + function getPassport( + address userAddress + ) external returns (Credential[] memory); +} diff --git a/contracts/IGitcoinResolver.sol b/contracts/IGitcoinResolver.sol index 65df487..36a2d60 100644 --- a/contracts/IGitcoinResolver.sol +++ b/contracts/IGitcoinResolver.sol @@ -1,22 +1,13 @@ // SPDX-License-Identifier: GPL pragma solidity ^0.8.9; -import { Initializable, OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; -import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; - -import { AttestationRequest, AttestationRequestData, EAS, Attestation, MultiAttestationRequest, IEAS } from "@ethereum-attestation-service/eas-contracts/contracts/EAS.sol"; -import { ISchemaResolver } from "@ethereum-attestation-service/eas-contracts/contracts/resolver/ISchemaResolver.sol"; -import { InvalidEAS } from "@ethereum-attestation-service/eas-contracts/contracts/Common.sol"; - -import { GitcoinAttester } from "./GitcoinAttester.sol"; - /** * @title IGitcoinResolver * @notice Minimal interface for consuming GitcoinResolver data */ -interface IGitcoinResolver -{ - function getUserAttestation(address user, bytes32 schema) external view returns (bytes32); - +interface IGitcoinResolver { + function getUserAttestation( + address user, + bytes32 schema + ) external view returns (bytes32); } diff --git a/contracts/mocks/GitcoinPassportDecoderUpdate.sol b/contracts/mocks/GitcoinPassportDecoderUpdate.sol new file mode 100644 index 0000000..71977a1 --- /dev/null +++ b/contracts/mocks/GitcoinPassportDecoderUpdate.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL +pragma solidity ^0.8.9; + +import { Initializable, OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { Attestation, IEAS } from "@ethereum-attestation-service/eas-contracts/contracts/EAS.sol"; + +import { GitcoinResolver } from "../GitcoinResolver.sol"; + +import "hardhat/console.sol"; + +/** + * @title GitcoinPassportDecoder + * @notice This contract is used to create the bit map of stamp providers onchain, which will allow us to score Passports fully onchain + */ + +contract GitcoinPassportDecoderUpdate is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + PausableUpgradeable +{ + // The instance of the EAS contract. + IEAS eas; + + // Mapping of version to provider arrays + mapping(uint32 => string[]) public providerVersions; + + // Version number + uint32 public version; + + // Address of the GitcoinResolver contract + address public gitcoinResolverAddress; + + // Passport credential struct + struct Credential { + string provider; + bytes32 hash; + uint64 issuanceDate; + uint64 expirationDate; + } + + function initialize(address _gitcoinResolverAddress) public initializer { + __Ownable_init(); + __Pausable_init(); + + gitcoinResolverAddress = _gitcoinResolverAddress; + } + + function pause() public onlyOwner { + _pause(); + } + + function unpause() public onlyOwner { + _unpause(); + } + + function _authorizeUpgrade(address) internal override onlyOwner {} + + function finaltest() pure public returns (uint) { + return 1; + } + + /** + * @dev Sets the address of the EAS contract. + * @param _easContractAddress The address of the EAS contract. + */ + function setEASAddress(address _easContractAddress) public onlyOwner { + eas = IEAS(_easContractAddress); + } + + /** + * @dev Adds a new provider to the end of the providerVersions mapping + * @param provider Name of individual provider + */ + function addProvider(string memory provider) external onlyOwner { + providerVersions[version].push(provider); + } + + /** + * @dev Creates a new provider. + * @param providerNames Array of provider names + */ + function createNewVersion(string[] memory providerNames) external onlyOwner { + version++; + providerVersions[version] = providerNames; + } + + function getAttestation(bytes32 attestationUID) public view returns (Attestation memory) { + Attestation memory attestation = eas.getAttestation(attestationUID); + return attestation; + } + + /** + * @dev Retrieves the user's Passport via the GitcoinResolver and decodes the bits in the provider map to output a readable Passport + * @param userAddress User's address + * @param schemaUID The UID of the schema used to make the user's attestation + */ + // getPassport(address): Calls GitcoinResolver to get the passport, then uses the provider mapping to decode the bits in the provider bitmap. This function can handle any bitmap version. The version is stored in the attestation, and the contract has all the data for historical bitmap versions. This should also demux the dates and hashes, so that the user gets back a normal looking passport object. + function getPassport(address userAddress, bytes32 schemaUID) public view returns (Credential[] memory) { + // Set the GitcoinResolver + GitcoinResolver resolver = GitcoinResolver(gitcoinResolverAddress); + + // Get the attestation UID from the user's attestations + bytes32 attestationUID = resolver.userAttestations(userAddress, schemaUID); + + // Get the attestation from the user's attestation UID + Attestation memory attestation = getAttestation(attestationUID); + + // Set up the variables to assign the attestion data output to + uint256[] memory providers; + bytes32[] memory hashes; + uint64[] memory issuanceDates; + uint64[] memory expirationDates; + uint16 providerMapVersion; + + // Decode the attestion output + (providers, hashes, issuanceDates, expirationDates, providerMapVersion) = abi.decode( + attestation.data, + (uint256[], bytes32[], uint64[], uint64[], uint16) + ); + + // Set up the variables to record the bit and the index of the credential hash + uint256 bit; + uint256 hashIndex = 0; + + // Set the list of providers to the provider map version + string[] memory mappedProviders = providerVersions[providerMapVersion]; + + // Now we iterate over the providers array and check each bit that is set + // If a bit is set + // we set the hash, issuanceDate, expirationDate, and provider to a Credential struct + // then we push that struct to the passport storage array + + // Set the passport array to be returned to equal the length of the passport saved to storage (for the duration of the function call) + Credential[] memory passportMemoryArray = new Credential[](hashes.length); + + // Populate the passportMemoryArray + for (uint256 i = 0; i < providers.length; ) { + bit = 1; + uint256 provider = uint256(providers[i]); + for (uint256 j = 0; j < 256; ) { + // Check that the provider bit is set + // The provider bit is set --> set the provider, hash, issuance date, and expiration date to the stuct + if (provider & bit > 0) { + Credential memory credential; + uint256 mappedProvidersIndex = i * 256 + j; + + if (mappedProvidersIndex < mappedProviders.length) { + credential.provider = mappedProviders[mappedProvidersIndex]; + } + + if (hashIndex < hashes.length) { + credential.hash = hashes[hashIndex]; + } + + if (hashIndex < issuanceDates.length) { + credential.issuanceDate = issuanceDates[hashIndex]; + } + + if (hashIndex < expirationDates.length) { + credential.expirationDate = expirationDates[hashIndex]; + } + + if (hashIndex < hashes.length) { + passportMemoryArray[hashIndex] = credential; + } + + hashIndex += 1; + } + bit <<= 1; + unchecked { + ++j; + } + } + unchecked { + i += 256; + } + } + + // Return the memory passport array + return passportMemoryArray; + } +} \ No newline at end of file diff --git a/docs/00-onchain-data.md b/docs/00-onchain-data.md index 623448e..118df75 100644 --- a/docs/00-onchain-data.md +++ b/docs/00-onchain-data.md @@ -48,10 +48,12 @@ sequenceDiagram actor User participant App as Passport App participant IAM as IAM Service - participant Verifier as Verifier (gitcoin, onchain) - participant Attester as Attester (gitcoin, onchain) + participant Verifier as GitcoinVerifier (onchain) + participant Attester as GitcoinAttester (onchain) participant EAS - participant Resolver as Resolver (gitcoin, onchain) + participant Resolver as GitcoinResolver (onchain) + participant Decoder as GitcoinPassportDecoder + participant External as Passport Integrator (external) User->>App: "Write stamps onchain" App->>IAM: "Verify and attest payload" IAM-->>App: PassportAttestationRequest @@ -74,6 +76,24 @@ sequenceDiagram Verifier-->>App : deactivate Verifier App-->>User : display onchain status + + activate Decoder + External->>Decoder : getPassport(address) + + activate Resolver + Decoder->>Resolver : userAttestations(userAddress, schemaUID); + Resolver-->>Decoder : attestationUID + deactivate Resolver + + activate EAS + Decoder->>EAS : getAttestation(attestationUID) + EAS-->>Decoder : attestation + deactivate EAS + + Decoder->>Decoder : decodeAttestation(attestation); + + Decoder-->>External : Credential[] + deactivate Decoder ``` @@ -162,4 +182,11 @@ smart contract shall only validate and store date from trusted sources: - a trusted EAS contract - a trusted Attester + +## GitcoinPassportDecoder + +This is a convenience smart contract that can be used by any party to check for the on-chain passport attestation for a given ETH address. +See the documentation [How to Decode Passport Attestations +](./05-querying-passport-attestations-onchain.md) for more details. + _[← Back to README](..#other-topics)_ diff --git a/docs/05-querying-passport-attestations-onchain.md b/docs/05-querying-passport-attestations-onchain.md new file mode 100644 index 0000000..7eabc69 --- /dev/null +++ b/docs/05-querying-passport-attestations-onchain.md @@ -0,0 +1,66 @@ +# How to Decode Passport Attestations + + +## Intro + +The purpose of this document is to provide instructions on how to load and decode Passport attestions onchain for a given ETH address. It also outlines the implementation considerations for the `GitcoinPassportDecoder` smart contract. + +For details on the EAS schema used to store Gitcoin Passports onchain, please see: [Onchain Passport Attestation](./01-onchain-passport-attestation.md) + +## Explanation of the Smart Contracts Involved + +In order to understand how Passports and Passports Scores exist onchain, take a read of [Bringing Passport Data Onchain](./00-onchain-data.md#bringing-passport-data-onchain). + +In order to load the latest Passport or Score attestations from EAS, we need to perform the following steps: + +### Step 1: Get the attestation UID + +In order to find the attestation UID that is owned by a given ETH address for a given schema (like the passport schema) we will need to use the `GitcoinResolver` as this plays the role of an 'indexer' and will store the latest attestation UID for a given schema and for a given recipient. + +This means that knowing the schema UID (which will be attained internally) and an ETH address we can get the attestation UID for a users. + +### Step 2: Get the attestation + +Having the attestaion UID from Step 1, we can just use the `getAttestation` method of the EAS smart contract to load a user's attestation. + +### Step 3: Decode the attestation data + +The schema of the passport attestation is documented in [Onchain Passport Attestation](./01-onchain-passport-attestation.md). In order to decode it one will need to use the `abi.decode` function as shown in the snippet below: + +```sol +// Decode the attestion output +(providers, hashes, issuanceDates, expirationDates, providerMapVersion) = abi.decode( + attestation.data, + (uint256[], bytes32[], uint64[], uint64[], uint16) +); +``` + +### Step 4: Load the stamp / VC data from the attestation + +The format of the passport attestation (what each individual field contains) is described in the document [Onchain Passport Attestation](./01-onchain-passport-attestation.md). + +In order to decode the stamps that are saved in a Passport attestation, one needs to understand and keep track of all the stamp providers. +To optimise for space and gas costs, the `providers` field has been used as a bit array, with each bit being assigned to a given provider. + +But this bit map is not fixed, and can potentially change over time as some providers are removed and others are added to the providers of Gitcoin Passport. +This is why we need to track the versions of the provider map. This can be achieved in a simple mapping like: + +```sol +mapping(uint32 => string[]) public providerVersions; +``` + +This is how the `providerVersions` shall be used: + +- keep an array of strings (provider names) for each version +- each position in the array coresponds to 1 bit in the `providers` field in the attestation +- new providers can be added to any array in this map + +This how the `providerVersions` is meant to be used: + +- the current version used for pushing stamps onchain is typically the latest version present in this map +- adding new providers for the current version of the providers list can be done by simply appending new elements to the array +- removing providers from an array is not possible. When providers are removed from the Passport Application, then there are 2 ways to deal with this in the `providerVersions`: + - keep the current version of the providers array, and the deprecated providers will simply not be written onchain any more (1 bit from the `providers` field of the attestation will be unused). This typically makes sense when there is only a small number of unused field + - create a new `providers` list version. This makes sense if the number of unused bits in the `providers` field is higher and we want to reset this + +_[← Back to README](..#other-topics)_ \ No newline at end of file diff --git a/docs/06-scoring-attestations-onchain.md b/docs/06-scoring-attestations-onchain.md new file mode 100644 index 0000000..e69de29 diff --git a/scripts/deployPassportDecoder.ts b/scripts/deployPassportDecoder.ts new file mode 100644 index 0000000..61ae98b --- /dev/null +++ b/scripts/deployPassportDecoder.ts @@ -0,0 +1,48 @@ +// This script deals with deploying the GitcoinPassportDecoder on a given network + +import hre, { ethers, upgrades } from "hardhat"; +import { + assertEnvironment, + confirmContinue, + updateDeploymentsFile, + getAbi, + getAttesterAddress, + getEASAddress, + getResolverAddress, +} from "./lib/utils"; + +assertEnvironment(); + +export async function main() { + await confirmContinue({ + contract: "GitcoinPassportDecoder", + network: hre.network.name, + chainId: hre.network.config.chainId, + }); + + const GitcoinPassportDecoder = await ethers.getContractFactory("GitcoinPassportDecoder"); + const passportDecoder = await upgrades.deployProxy( + GitcoinPassportDecoder, + { + initializer: "initialize", + kind: "uups", + } + ); + + const deployment = await passportDecoder.waitForDeployment(); + + const passportDecoderAddress = await deployment.getAddress(); + + console.log(`✅ Deployed GitcoinPassportDecoder to ${passportDecoderAddress}.`); + + await updateDeploymentsFile( + "GitcoinPassportDecoder", + getAbi(deployment), + passportDecoderAddress + ); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/initializeChainInfo.ts b/scripts/initializeChainInfo.ts index 4e9d806..5865c52 100644 --- a/scripts/initializeChainInfo.ts +++ b/scripts/initializeChainInfo.ts @@ -27,6 +27,7 @@ export async function main() { GitcoinAttester: {}, GitcoinVerifier: {}, GitcoinResolver: {}, + GitcoinPassportDecoder: {}, easSchemas: { passport: { uid: "", diff --git a/scripts/lib/utils.ts b/scripts/lib/utils.ts index 43d6ca6..d225ac8 100644 --- a/scripts/lib/utils.ts +++ b/scripts/lib/utils.ts @@ -145,6 +145,7 @@ let thisChainInfo: { GitcoinAttester?: { address?: string }; GitcoinVerifier?: { address?: string }; GitcoinResolver?: { address?: string }; + GitcoinPassportDecoder?: { address?: string }; EAS?: { address?: string }; issuer?: { address?: string }; Verax?: { AttestationRegistry?: { address?: string } }; @@ -189,6 +190,13 @@ export function getResolverAddress() { return resolverAddress; } +export function getPassportDecoderAddress() { + const passportDecoderAddress = getThisChainInfo().GitcoinPassportDecoder?.address; + if (!passportDecoderAddress) + throw new Error("GitcoinPassportDecoder address not found in onchainInfo"); + return passportDecoderAddress; +} + export function getIssuerAddress() { const resolverAddress = getThisChainInfo().issuer?.address; if (!resolverAddress) diff --git a/scripts/transferOwnership.ts b/scripts/transferOwnership.ts index 877d8c2..2a4d085 100644 --- a/scripts/transferOwnership.ts +++ b/scripts/transferOwnership.ts @@ -7,6 +7,7 @@ import { getAttesterAddress, getVerifierAddress, getResolverAddress, + getPassportDecoderAddress, } from "./lib/utils"; assertEnvironment(); @@ -31,9 +32,13 @@ export async function main() { const GitcoinAttester = await ethers.getContractFactory("GitcoinAttester"); const attester = GitcoinAttester.attach(getAttesterAddress()); + const GitcoinPassportDecoder = await ethers.getContractFactory("GitcoinPassportDecoder"); + const passportDecoder = GitcoinPassportDecoder.attach(getPassportDecoderAddress()); + await transferOwnershipToMultisig(resolver); await transferOwnershipToMultisig(verifier); await transferOwnershipToMultisig(attester); + await transferOwnershipToMultisig(passportDecoder); } main().catch((error) => { diff --git a/scripts/upgradePassportDecoder.ts b/scripts/upgradePassportDecoder.ts new file mode 100644 index 0000000..6be8ff7 --- /dev/null +++ b/scripts/upgradePassportDecoder.ts @@ -0,0 +1,53 @@ +import hre, { ethers, upgrades } from "hardhat"; +import { + confirmContinue, + assertEnvironment, + updateDeploymentsFile, + getAbi, + getPassportDecoderAddress, +} from "./lib/utils"; + +assertEnvironment(); + +export async function main() { + await confirmContinue({ + contract: "GitcoinPassportDecoder", + network: hre.network.name, + chainId: hre.network.config.chainId, + }); + + const passportDecoderAddress = getPassportDecoderAddress(); + + const GitcoinPassportDecoder = await ethers.getContractFactory( + "GitcoinPassportDecoder" + ); + + const preparedUpgradeAddress = await upgrades.prepareUpgrade( + passportDecoderAddress, + GitcoinPassportDecoder, + { + kind: "uups", + redeployImplementation: "always", + } + ); + + console.log( + `✅ Deployed Upgraded GitcoinPassportDecoder. ${preparedUpgradeAddress}` + ); + + await updateDeploymentsFile("GitcoinPassportDecoder", getAbi(GitcoinPassportDecoder)); + + const gitcoinPassportDecoder = GitcoinPassportDecoder.attach(passportDecoderAddress); + + const upgradeData = gitcoinPassportDecoder.interface.encodeFunctionData( + "upgradeTo", + [preparedUpgradeAddress] + ); + + console.log({ upgradeData }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/GitcoinPassportDecoder.ts b/test/GitcoinPassportDecoder.ts new file mode 100644 index 0000000..adcd337 --- /dev/null +++ b/test/GitcoinPassportDecoder.ts @@ -0,0 +1,406 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { + SchemaEncoder, + ZERO_BYTES32, +} from "@ethereum-attestation-service/eas-sdk"; +import { SCHEMA_REGISTRY_ABI } from "./abi/SCHEMA_REGISTRY_ABI"; +import { schemaRegistryContractAddress } from "./GitcoinResolver"; +import { + passportTypes, + fee1, + EAS_CONTRACT_ADDRESS, +} from "./helpers/verifierTests"; + +const providers = [BigInt("111")]; +const issuanceDates = [1694628559, 1695047108, 1693498086]; +const expirationDates = [1702404559, 1702823108, 1701274086]; +const hashes = [ + ethers.getBytes("0xf760285ed09eb7bb0da39df5abd0adb608d410b357ab6415644d2b49aa64e5f1"), + ethers.getBytes("0x29b3eb7b8ee47cb0a9d83e7888f05ea5f61e3437752602282e18129d2d8b4024"), + ethers.getBytes("0x84c6f60094c95180e54fac3e9a5cfde8ca430e598e987504474151a219ae0d13"), +]; +const providerMapVersion = 1; + +const invalidIssuanceDates = [1694628559, 1695047108, 1693498086, 1695047109]; +const invalidExpirationDates = [1702404559, 1702823108]; +const invalidHashes = [ + ethers.getBytes("0xf760285ed09eb7bb0da39df5abd0adb608d410b357ab6415644d2b49aa64e5f1"), + ethers.getBytes("0x29b3eb7b8ee47cb0a9d83e7888f05ea5f61e3437752602282e18129d2d8b4024"), + ethers.getBytes("0x84c6f60094c95180e54fac3e9a5cfde8ca430e598e987504474151a219ae0d13"), + ethers.getBytes("0x84c6f60094c95180e54fac3e9a5cfde8ca430e598e987504474151a219ab1d24"), + ethers.getBytes("0x84c6f60094c95180e54fac3e9a5cfde8ca430e598e987504474151a219af2d35"), +]; + +const easEncodeStamp = () => { + const schemaEncoder = new SchemaEncoder( + "uint256[] providers, bytes32[] hashes, uint64[] issuanceDates, uint64[] expirationDates, uint16 providerMapVersion" + ); + + const encodedData = schemaEncoder.encodeData([ + { name: "providers", value: providers, type: "uint256[]" }, + { name: "hashes", value: hashes, type: "bytes32[]" }, + { name: "issuanceDates", value: issuanceDates, type: "uint64[]" }, + { name: "expirationDates", value: expirationDates, type: "uint64[]" }, + { name: "providerMapVersion", value: providerMapVersion, type: "uint16" }, + ]); + return encodedData; +} + +const easEncodeInvalidStamp = () => { + const schemaEncoder = new SchemaEncoder( + "uint256[] providers, bytes32[] hashes, uint64[] issuanceDates, uint64[] expirationDates, uint16 providerMapVersion" + ); + + const encodedData = schemaEncoder.encodeData([ + { name: "providers", value: providers, type: "uint256[]" }, + { name: "hashes", value: invalidHashes, type: "bytes32[]" }, + { name: "issuanceDates", value: invalidIssuanceDates, type: "uint64[]" }, + { name: "expirationDates", value: invalidExpirationDates, type: "uint64[]" }, + { name: "providerMapVersion", value: providerMapVersion, type: "uint16" }, + ]); + return encodedData; +} + +describe("GitcoinPassportDecoder", function () { + this.beforeAll(async function () { + const [ownerAccount, iamAcct, recipientAccount, otherAccount] = + await ethers.getSigners(); + + this.owner = ownerAccount; + this.iamAccount = iamAcct; + this.recipient = recipientAccount; + this.otherAcct = otherAccount; + + // Deploy GitcoinAttester + const GitcoinAttester = await ethers.getContractFactory( + "GitcoinAttester", + this.owner + ); + this.gitcoinAttester = await GitcoinAttester.deploy(); + await this.gitcoinAttester.connect(this.owner).initialize(); + + // Deploy GitcoinVerifier + const GitcoinVerifier = await ethers.getContractFactory( + "GitcoinVerifier", + this.owner + ); + this.gitcoinVerifier = await GitcoinVerifier.deploy(); + + this.gitcoinAttesterAddress = await this.gitcoinAttester.getAddress(); + await this.gitcoinAttester.setEASAddress(EAS_CONTRACT_ADDRESS); + + await this.gitcoinVerifier + .connect(this.owner) + .initialize( + await this.iamAccount.getAddress(), + await this.gitcoinAttester.getAddress() + ); + + const chainId = await ethers.provider + .getNetwork() + .then((n: { chainId: any }) => n.chainId); + + this.domain = { + name: "GitcoinVerifier", + version: "1", + chainId, + verifyingContract: await this.gitcoinVerifier.getAddress(), + }; + + this.getNonce = async (address: string) => { + return await this.gitcoinVerifier.recipientNonces(address); + }; + + this.uid = ethers.keccak256(ethers.toUtf8Bytes("test")); + + // Deploy GitcoinResolver + const GitcoinResolver = await ethers.getContractFactory( + "GitcoinResolver", + this.owner + ); + this.gitcoinResolver = await GitcoinResolver.deploy(); + await this.gitcoinResolver + .connect(this.owner) + .initialize( + EAS_CONTRACT_ADDRESS, + await this.gitcoinAttester.getAddress() + ); + + // Register schema for resolver + const schemaRegistry = new ethers.Contract( + ethers.getAddress(schemaRegistryContractAddress), + SCHEMA_REGISTRY_ABI, + this.owner + ); + + this.stampSchemaInput = "uint256[] providers, bytes32[] hashes, uint64[] issuanceDates, uint64[] expirationDates, uint16 providerMapVersion"; + this.resolverAddress = await this.gitcoinResolver.getAddress(); + this.revocable = true; + + this.stampTx = await schemaRegistry.register( + this.stampSchemaInput, + this.resolverAddress, + this.revocable + ); + + this.passportSchemaTxReceipt = await this.stampTx.wait(); + const passportSchemaEvent = this.passportSchemaTxReceipt.logs.filter( + (log: any) => { + return log.fragment.name == "Registered"; + } + ); + this.passportSchemaUID = passportSchemaEvent[0].args[0]; + + this.passport = { + multiAttestationRequest: [ + { + schema: this.passportSchemaUID, + data: [ + { + recipient: this.recipient.address, + expirationTime: 1708741995, + revocable: true, + refUID: ZERO_BYTES32, + data: easEncodeStamp(), + value: 0, + }, + ], + }, + ], + nonce: await this.getNonce(this.recipient.address), + fee: fee1, + }; + + this.invalidPassport = { + multiAttestationRequest: [ + { + schema: this.passportSchemaUID, + data: [ + { + recipient: this.recipient.address, + expirationTime: 1708741995, + revocable: true, + refUID: ZERO_BYTES32, + data: easEncodeInvalidStamp(), + value: 0, + }, + ], + }, + ], + nonce: await this.getNonce(this.recipient.address), + fee: fee1, + }; + + const addVerifierResult = await this.gitcoinAttester + .connect(this.owner) + .addVerifier(await this.gitcoinVerifier.getAddress()); + + await addVerifierResult.wait(); + + const GitcoinPassportDecoder = await ethers.getContractFactory( + "GitcoinPassportDecoder", + this.owner, + ); + + this.gitcoinPassportDecoder = await GitcoinPassportDecoder.deploy(); + + await this.gitcoinPassportDecoder + .connect(this.owner) + .initialize(); + + // Initialize the sdk with the address of the EAS Schema contract address + await this.gitcoinPassportDecoder.setEASAddress(EAS_CONTRACT_ADDRESS); + await this.gitcoinPassportDecoder.setGitcoinResolver(this.resolverAddress); + await this.gitcoinPassportDecoder.setSchemaUID(this.passportSchemaUID); + }); + + this.beforeEach(async function () { + this.passport.nonce = await this.gitcoinVerifier.recipientNonces( + this.passport.multiAttestationRequest[0].data[0].recipient + ); + }); + + describe("Creating new versions", function () { + it("should add new providers to the providers mapping and increment the version", async function () { + const providers = ["NewStamp1", "NewStamp2"]; + // Get the 0th version + const versionZero = await this.gitcoinPassportDecoder.currentVersion(); + + expect(versionZero === 0); + + await this.gitcoinPassportDecoder.connect(this.owner).createNewVersion(providers); + + // Get the current version + const currentVersion = await this.gitcoinPassportDecoder.currentVersion(); + + expect(currentVersion === 1); + + const firstProvider = await this.gitcoinPassportDecoder.providerVersions(currentVersion, 0); + + expect(firstProvider === providers[0]); + }); + + it("should not allow anyone other than owner to add new providers to the mapping", async function () { + const providers = ["NewStamp1", "NewStamp2"]; + // Get the 0th version + const versionZero = await this.gitcoinPassportDecoder.currentVersion(); + + expect(versionZero === 0); + + await expect(this.gitcoinPassportDecoder.connect(this.recipient).createNewVersion(providers)).to.be.revertedWith("Ownable: caller is not the owner"); + }); + }); + + describe("Adding new providers to current version of providers", async function () { + it("should append a provider to the end of an existing provider mapping", async function () { + const providers = ["NewStamp1", "NewStamp2"]; + + await this.gitcoinPassportDecoder.connect(this.owner).createNewVersion(providers); + + await this.gitcoinPassportDecoder.connect(this.owner).addProvider("NewStamp3"); + + const currentVersion = await this.gitcoinPassportDecoder.currentVersion(); + + const lastProvider = await this.gitcoinPassportDecoder.providerVersions(currentVersion, 2); + + expect(lastProvider === "NewStamp3"); + }); + + it("should not allow anyone other than owner to append new providers array in the provider mapping", async function () { + const providers = ["NewStamp1", "NewStamp2"]; + + await expect(this.gitcoinPassportDecoder.connect(this.recipient).addProvider("NewStamp3")).to.be.revertedWith("Ownable: caller is not the owner"); + }); + }); + + describe("Decoding Passports", async function () { + const mappedProviders = ["Twitter", "Google", "Ens"]; + + it("should decode a user's passport", async function () { + await this.gitcoinPassportDecoder.connect(this.owner).createNewVersion(mappedProviders); + + const signature = await this.iamAccount.signTypedData( + this.domain, + passportTypes, + this.passport + ); + + const { v, r, s } = ethers.Signature.from(signature); + + // Submit attestations + const verifiedPassport = await this.gitcoinVerifier.verifyAndAttest( + this.passport, + v, + r, + s, + { + value: fee1, + } + ); + + await verifiedPassport.wait(); + + const passportTx = await this.gitcoinPassportDecoder + .connect(this.owner) + .getPassport(this.recipient.address); + + expect(passportTx.length === mappedProviders.length); + + passportTx.forEach((cred: any) => { + mappedProviders.forEach((provider: string) => { + expect(cred[0] === provider); + }); + hashes.forEach((hash: Uint8Array) => { + expect(cred[1] === hash); + }); + issuanceDates.forEach((issuanceDate: number) => { + expect(cred[2] === issuanceDate); + }); + expirationDates.forEach((expirationDate: number) => { + expect(cred[3] === expirationDate); + }); + }); + }); + + it("should allow non-owners to decode a user's passport", async function () { + await this.gitcoinPassportDecoder.connect(this.owner).createNewVersion(mappedProviders); + + const signature = await this.iamAccount.signTypedData( + this.domain, + passportTypes, + this.passport + ); + + const { v, r, s } = ethers.Signature.from(signature); + + // Submit attestations + const verifiedPassport = await this.gitcoinVerifier.verifyAndAttest( + this.passport, + v, + r, + s, + { + value: fee1, + } + ); + + await verifiedPassport.wait(); + + const passportTx = await this.gitcoinPassportDecoder + .connect(this.otherAcct) + .getPassport(this.recipient.address); + + expect(passportTx.length === mappedProviders.length); + + passportTx.forEach((cred: any) => { + mappedProviders.forEach((provider: string) => { + expect(cred[0] === provider); + }); + hashes.forEach((hash: Uint8Array) => { + expect(cred[1] === hash); + }); + issuanceDates.forEach((issuanceDate: number) => { + expect(cred[2] === issuanceDate); + }); + expirationDates.forEach((expirationDate: number) => { + expect(cred[3] === expirationDate); + }); + }); + }); + + it("should verify assertions in contract are working via invalid data", async function () { + this.invalidPassport.nonce = await this.gitcoinVerifier.recipientNonces( + this.invalidPassport.multiAttestationRequest[0].data[0].recipient + ); + + await this.gitcoinPassportDecoder.connect(this.owner).createNewVersion(mappedProviders); + + const signature = await this.iamAccount.signTypedData( + this.domain, + passportTypes, + this.invalidPassport + ); + + const { v, r, s } = ethers.Signature.from(signature); + + // Submit attestations + const verifiedPassport = await this.gitcoinVerifier.verifyAndAttest( + this.invalidPassport, + v, + r, + s, + { + value: fee1, + } + ); + + await verifiedPassport.wait(); + + expect(this.gitcoinPassportDecoder + .connect(this.owner) + .getPassport(this.recipient.address) + ).to.be.revertedWithPanic(); + }); + }); +}); \ No newline at end of file diff --git a/test/Upgrades.ts b/test/Upgrades.ts index fc81a87..5c70554 100644 --- a/test/Upgrades.ts +++ b/test/Upgrades.ts @@ -152,4 +152,71 @@ describe("Upgrading GitcoinResolver", function () { this.resolverProxyAddress ); }); + + describe("Upgrading GitcoinPassportDecoder", function () { + this.beforeEach(async function () { + const [owner, mockEASAccount] = await ethers.getSigners(); + this.owner = owner; + this.mockEAS = mockEASAccount; + }); + + it("should deploy GitcoinPassportDecoder and proxy contract", async function () { + const GitcoinAttester = await ethers.getContractFactory("GitcoinAttester"); + const gitcoinAttester = await upgrades.deployProxy(GitcoinAttester, { + kind: "uups", + }); + const gitcoinAttesterAddress = await gitcoinAttester.getAddress(); + + const GitcoinResolver = await ethers.getContractFactory("GitcoinResolver"); + const gitcoinResolver = await upgrades.deployProxy( + GitcoinResolver, + [this.mockEAS.address, gitcoinAttesterAddress], + { + initializer: "initialize", + kind: "uups", + } + ); + + const gitcoinResolverAddress = await gitcoinResolver.getAddress(); + this.resolverProxyAddress = gitcoinResolverAddress; + this.gitcoinResolverProxy = gitcoinResolver; + + const GitcoinPassportDecoder = await ethers.getContractFactory("GitcoinPassportDecoder"); + const gitcoinPassportDecoder = await upgrades.deployProxy( + GitcoinPassportDecoder, + { + initializer: "initialize", + kind: "uups", + } + ); + + const gitcoinPassportDecoderAddress = await gitcoinPassportDecoder.getAddress(); + this.passportDecoderProxyAddress = gitcoinPassportDecoderAddress; + this.gitcoinPassportDecoderProxy = gitcoinPassportDecoder; + + expect(gitcoinPassportDecoderAddress).to.not.be.null; + }); + + it("should upgrade GitcoinPassportResolver implementation", async function () { + const GitcoinPassportDecoder = await ethers.getContractFactory( + "GitcoinPassportDecoder" + ); + + const preparedUpgradeAddress = await upgrades.prepareUpgrade( + this.passportDecoderProxyAddress, + GitcoinPassportDecoder, + { + kind: "uups", + redeployImplementation: "always", + } + ); + + const upgradeCall = await this.gitcoinPassportDecoderProxy.upgradeTo( + preparedUpgradeAddress as string + ); + expect(await this.gitcoinPassportDecoderProxy.getAddress()).to.be.equal( + this.passportDecoderProxyAddress + ); + }); + }); });