diff --git a/package.json b/package.json index 591c1c1d..82ab85c5 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,10 @@ "./http": { "import": "./dist/esm/http.js", "require": "./dist/cjs/http.js" + }, + "./protocol": { + "import": "./dist/esm/protocol.js", + "require": "./dist/cjs/protocol.js" } }, "files": [ @@ -142,19 +146,21 @@ }, "dependencies": { "@datadog/browser-logs": "^4.42.2", + "@ethereum-attestation-service/eas-sdk": "^0.29.1", "@prisma/client": "^5.7.1", "async-sema": "^3.1.1", "fetch-retry": "^5.0.6", "lodash": "^4.17.21", "loglevel": "^1.8.1", "luxon": "^3.3.0", + "merkletreejs": "^0.4.0", "nanoid": "^3.3.6", "nanoid-dictionary": "^3.0.0", "prisma": "^5.7.1", "undici": "^6.19.5", "utf-8-validate": "^5.0.10", "uuid": "^9.0.0", - "viem": "^1.18.3" + "viem": "^2.21.42" }, "license": "GPLv3" } diff --git a/src/lib/protocol/easSchemas/constants.ts b/src/lib/protocol/easSchemas/constants.ts new file mode 100644 index 00000000..f7d9b242 --- /dev/null +++ b/src/lib/protocol/easSchemas/constants.ts @@ -0,0 +1,8 @@ +import { getSchemaUID } from '@ethereum-attestation-service/eas-sdk'; + +export const NULL_EAS_REF_UID = '0x0000000000000000000000000000000000000000000000000000000000000000'; + +export const NULL_EVM_ADDRESS = '0x0000000000000000000000000000000000000000'; + +// Obtained from https://github.com/ethereum-attestation-service/eas-contracts/blob/558250dae4cb434859b1ac3b6d32833c6448be21/deploy/scripts/000004-name-initial-schemas.ts#L10C1-L11C1 +export const NAME_SCHEMA_UID = getSchemaUID('bytes32 schemaId,string name', NULL_EVM_ADDRESS, true); diff --git a/src/lib/protocol/easSchemas/githubContributionReceiptSchema.ts b/src/lib/protocol/easSchemas/githubContributionReceiptSchema.ts new file mode 100644 index 00000000..59cd63bc --- /dev/null +++ b/src/lib/protocol/easSchemas/githubContributionReceiptSchema.ts @@ -0,0 +1,56 @@ +import { SchemaEncoder } from '@ethereum-attestation-service/eas-sdk'; + +export const githubContributionReceiptEASSchema = + 'bytes32 userRefUID,string description,string url,string metadataUrl,uint256 value,string type'; + +export const githubContributionReceiptSchemaName = 'Github Contribution Receipt'; + +export type GithubContributionReceiptAttestation = { + userRefUID: string; + description: string; + url: string; + metadataUrl: string; + value: number; + type: string; +}; + +const encoder = new SchemaEncoder(githubContributionReceiptEASSchema); + +export function encodeGithubContributionReceiptAttestation( + attestation: GithubContributionReceiptAttestation +): `0x${string}` { + const encodedData = encoder.encodeData([ + { name: 'userRefUID', type: 'string', value: attestation.userRefUID }, + { name: 'description', type: 'string', value: attestation.description }, + { + name: 'url', + type: 'string', + value: attestation.url + }, + { + name: 'metadataUrl', + type: 'string', + value: attestation.metadataUrl + }, + { name: 'value', type: 'uint256', value: attestation.value }, + { name: 'type', type: 'string', value: attestation.type } + ]); + + return encodedData as `0x${string}`; +} + +export function decodeGithubContributionReceiptAttestation(rawData: string): GithubContributionReceiptAttestation { + const parsed = encoder.decodeData(rawData); + const values = parsed.reduce((acc, item) => { + const key = item.name as keyof GithubContributionReceiptAttestation; + + if (key === 'value') { + acc[key] = parseInt(item.value.value as string); + } else { + acc[key] = item.value.value as string; + } + return acc; + }, {} as GithubContributionReceiptAttestation); + + return values as GithubContributionReceiptAttestation; +} diff --git a/src/lib/protocol/easSchemas/index.ts b/src/lib/protocol/easSchemas/index.ts new file mode 100644 index 00000000..adb02bfa --- /dev/null +++ b/src/lib/protocol/easSchemas/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './githubContributionReceiptSchema'; +export * from './scoutGameUserProfileSchema'; diff --git a/src/lib/protocol/easSchemas/scoutGameUserProfileSchema.ts b/src/lib/protocol/easSchemas/scoutGameUserProfileSchema.ts new file mode 100644 index 00000000..cc6a7e0b --- /dev/null +++ b/src/lib/protocol/easSchemas/scoutGameUserProfileSchema.ts @@ -0,0 +1,34 @@ +import { SchemaEncoder } from '@ethereum-attestation-service/eas-sdk'; + +export const scoutGameUserProfileEASSchema = 'string id,string metadataUrl'; + +export const scoutGameUserProfileSchemaName = 'Scout Game User Profile'; + +export type ScoutGameUserProfileAttestation = { + id: string; + metadataUrl: string; +}; + +const encoder = new SchemaEncoder(scoutGameUserProfileEASSchema); + +export function encodeScoutGameUserProfileAttestation(attestation: ScoutGameUserProfileAttestation): `0x${string}` { + const encodedData = encoder.encodeData([ + { name: 'id', type: 'string', value: attestation.id }, + { name: 'metadataUrl', type: 'string', value: attestation.metadataUrl } + ]); + + return encodedData as `0x${string}`; +} + +export function decodeScoutGameUserProfileAttestation(rawData: string): ScoutGameUserProfileAttestation { + const parsed = encoder.decodeData(rawData); + const values = parsed.reduce((acc, item) => { + const key = item.name as keyof ScoutGameUserProfileAttestation; + + acc[key] = item.value.value as string; + + return acc; + }, {} as ScoutGameUserProfileAttestation); + + return values as ScoutGameUserProfileAttestation; +} diff --git a/src/lib/protocol/proofs/__tests__/verifyClaim.spec.ts b/src/lib/protocol/proofs/__tests__/verifyClaim.spec.ts new file mode 100644 index 00000000..e582bd88 --- /dev/null +++ b/src/lib/protocol/proofs/__tests__/verifyClaim.spec.ts @@ -0,0 +1,70 @@ +import { generateMerkleTree, getMerkleProofs, verifyMerkleClaim, type ProvableClaim } from '../merkleTree'; + +const claimsInput: ProvableClaim[] = [ + { + // Key here so we can copy to other tests: 57b7b9b29419b66ac8156f844a7b0eb18d94f729699b3f15a3d8817d3f5980a3 + address: '0x3F2A655d4e39E6c4470703e1063e9a843586886A', + amount: 100 + }, + { + // Key here so we can copy to other tests: aa03d22263ff3e4df4105a20d08f62873f5e100974862fdc1f99083ba11e6adc + address: '0x2Fe1B8C9C8722f0D3e5B9a9D4115559bB8f04931', + amount: 200 + }, + { + // Key here so we can copy to other tests: c674865dde0163f480f818a78fc4d316c64d60b05666600734df8e8f37147f64 + address: '0x03F8B139fF6dbbb7475bAA5A71c16fcDD9495cc4', + amount: 300 + }, + { + address: '0x36446eF671954753801f9d73C415a80C0e550b32', + amount: 400 + }, + { + address: '0xD02953857250D32EC72064d9E2320B43296E52C0', + amount: 500 + } +]; + +describe('verifyMerkleClaim', () => { + it('should return true if the claim is valid', () => { + const { tree } = generateMerkleTree(claimsInput); + const claim = claimsInput[0]; + const proofs = getMerkleProofs(tree, claim); + + expect(verifyMerkleClaim(tree, claim, proofs)).toBe(true); + }); + + it('should return false if the claim is invalid', () => { + const { tree } = generateMerkleTree(claimsInput); + const claim: ProvableClaim = { + address: '0x36446eF671954753801f9d73C415a80C0e550b32', + amount: 200 + }; + const proof = getMerkleProofs(tree, claim); + expect(verifyMerkleClaim(tree, claim, proof)).toBe(false); + }); + + it('should sort inputs so that it is not reliant on ordering of the claims', () => { + function shuffleArray(array: T[]): T[] { + const newArray = [...array]; // Create a copy of the array to avoid mutating the original + for (let i = newArray.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; // Swap elements + } + return newArray; + } + + const shuffledOne = shuffleArray(claimsInput); + + const shuffledTwo = shuffleArray(claimsInput); + + // Make sure sorting worked + expect(JSON.stringify(shuffledOne)).not.toEqual(JSON.stringify(shuffledTwo)); + + const { rootHash: rootHashOne } = generateMerkleTree(shuffledOne); + const { rootHash: rootHashTwo } = generateMerkleTree(shuffledTwo); + + expect(rootHashOne).toEqual(rootHashTwo); + }); +}); diff --git a/src/lib/protocol/proofs/merkleTree.ts b/src/lib/protocol/proofs/merkleTree.ts new file mode 100644 index 00000000..60999989 --- /dev/null +++ b/src/lib/protocol/proofs/merkleTree.ts @@ -0,0 +1,37 @@ +import { MerkleTree } from 'merkletreejs'; +import type { Address } from 'viem'; +import { keccak256, encodePacked } from 'viem/utils'; + +export type ProvableClaim = { + address: Address; + amount: number; +}; + +function hashLeaf(claim: ProvableClaim): string { + // Mimic Solidity's keccak256(abi.encodePacked(address, amount)) + const packedData = encodePacked(['address', 'uint256'], [claim.address, BigInt(claim.amount)]); + return keccak256(packedData); +} + +export function generateMerkleTree(claims: ProvableClaim[]): { tree: MerkleTree; rootHash: string } { + const inputs = claims.map(hashLeaf); + + const tree = new MerkleTree(inputs, keccak256, { + sort: true + // concatenator: ([left, right]) => Buffer.from(hashLeaf(Buffer.concat([left, right]).toString())) + }); + + return { + tree, + rootHash: tree.getRoot().toString('hex') + }; +} + +export function getMerkleProofs(tree: MerkleTree, claim: ProvableClaim): string[] { + return tree.getHexProof(hashLeaf(claim)); +} + +export function verifyMerkleClaim(tree: MerkleTree, claim: ProvableClaim, proof: string[]): boolean { + const root = tree.getRoot().toString('hex'); + return tree.verify(proof, hashLeaf(claim), root); +} diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 00000000..d2bc2abd --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,2 @@ +export * from './lib/protocol/proofs/merkleTree'; +export * from './lib/protocol/easSchemas/index';