Skip to content

Commit

Permalink
Add weekly claims model (#385)
Browse files Browse the repository at this point in the history
* Add weekly claims model

* ci: version bump to 0.93.1-rc-weekly-claims.0

* Add onchain claims

* ci: version bump to 0.93.2-rc-weekly-claims.0

* Relocate protocol eas and merkle proofs to core

* ci: version bump to 0.94.1-rc-weekly-claims.0

* Fix encoder

* Fix userRefUID typing

* ci: version bump to 0.94.1-rc-weekly-claims.1

* Bump version

* ci: version bump to 0.94.1-rc-weekly-claims.3

* Fix type for merkle tree proofs return type

* ci: version bump to 0.94.1-rc-weekly-claims.4

* Cleanup schema definitions

* ci: version bump to 0.94.1-rc-weekly-claims.5

* Add encoding for name schema args

* ci: version bump to 0.94.1-rc-weekly-claims.6

* Fix encoder for name schema

* ci: version bump to 0.94.1-rc-weekly-claims.7

* Add onchain scout profile

* ci: version bump to 0.94.1-rc-weekly-claims.8

* Rename the schema

* Fix build

* Fix schema name

* ci: version bump to 0.94.1-rc-weekly-claims.9

* ci: version bump to 0.94.1-rc-weekly-claims.10

* Resync prisma with main branch

* ci: version bump to 0.95.1-rc-weekly-claims.0

---------

Co-authored-by: Automated Version Bump <[email protected]>
  • Loading branch information
motechFR and Automated Version Bump authored Nov 10, 2024
1 parent b89ba02 commit d5a3b56
Show file tree
Hide file tree
Showing 11 changed files with 423 additions and 58 deletions.
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@charmverse/core",
"version": "0.95.0",
"version": "0.95.1-rc-weekly-claims.0",
"description": "Core API for Charmverse",
"type": "commonjs",
"types": "./dist/cjs/index.d.ts",
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
}
25 changes: 25 additions & 0 deletions src/lib/protocol/easSchemas/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { getSchemaUID, SchemaEncoder } from '@ethereum-attestation-service/eas-sdk';

export const NULL_EAS_REF_UID = '0x0000000000000000000000000000000000000000000000000000000000000000';

export const NULL_EVM_ADDRESS = '0x0000000000000000000000000000000000000000';

// This allows us to encode the schemaId and name of a name schema attestation
// 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_DEFINITION = 'bytes32 schemaId,string name';

export const NAME_SCHEMA_UID = getSchemaUID(NAME_SCHEMA_DEFINITION, NULL_EVM_ADDRESS, true) as `0x${string}`;

export type NameSchemaAttestation = {
schemaId: `0x${string}`;
name: string;
};

export function encodeNameSchemaAttestation({ name, schemaId }: NameSchemaAttestation): `0x${string}` {
const encoder = new SchemaEncoder(NAME_SCHEMA_DEFINITION);

return encoder.encodeData([
{ name: 'schemaId', type: 'bytes32', value: schemaId },
{ name: 'name', type: 'string', value: name }
]) as `0x${string}`;
}
62 changes: 62 additions & 0 deletions src/lib/protocol/easSchemas/contributionReceiptSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { SchemaEncoder } from '@ethereum-attestation-service/eas-sdk';
import type { EASSchema } from 'protocol';

const contributionReceiptEASSchema =
'bytes32 userRefUID,string description,string url,string metadataUrl,uint256 value,string type';

const contributionReceiptSchemaName = 'Contribution Receipt';

export const contributionSchemaDefinition: EASSchema = {
schema: contributionReceiptEASSchema,
name: contributionReceiptSchemaName
};

export type ContributionReceiptAttestation = {
userRefUID: `0x${string}`;
description: string;
url: string;
metadataUrl: string;
value: number;
type: string;
};

const encoder = new SchemaEncoder(contributionReceiptEASSchema);

export function encodeContributionReceiptAttestation(attestation: ContributionReceiptAttestation): `0x${string}` {
const encodedData = encoder.encodeData([
{ name: 'userRefUID', type: 'bytes32', 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 decodeContributionReceiptAttestation(rawData: string): ContributionReceiptAttestation {
const parsed = encoder.decodeData(rawData);
const values = parsed.reduce((acc, item) => {
const key = item.name as keyof ContributionReceiptAttestation;

if (key === 'value') {
acc[key] = parseInt(item.value.value as string);
} else if (key === 'userRefUID') {
acc[key] = item.value.value as `0x${string}`;
} else {
acc[key] = item.value.value as string;
}
return acc;
}, {} as ContributionReceiptAttestation);

return values as ContributionReceiptAttestation;
}
10 changes: 10 additions & 0 deletions src/lib/protocol/easSchemas/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { contributionSchemaDefinition } from './contributionReceiptSchema';
import { scoutGameUserProfileSchemaDefinition } from './scoutGameUserProfileSchema';
import type { EASSchema } from './types';

export * from './constants';
export * from './contributionReceiptSchema';
export * from './scoutGameUserProfileSchema';
export * from './types';

export const allSchemas: EASSchema[] = [contributionSchemaDefinition, scoutGameUserProfileSchemaDefinition];
40 changes: 40 additions & 0 deletions src/lib/protocol/easSchemas/scoutGameUserProfileSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { SchemaEncoder } from '@ethereum-attestation-service/eas-sdk';
import type { EASSchema } from 'protocol';

const scoutGameUserProfileEASSchema = 'string id,string metadataUrl';

const scoutGameUserProfileSchemaName = 'Scout Game User Profile';

export const scoutGameUserProfileSchemaDefinition: EASSchema = {
schema: scoutGameUserProfileEASSchema,
name: scoutGameUserProfileSchemaName
};

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;
}
4 changes: 4 additions & 0 deletions src/lib/protocol/easSchemas/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type EASSchema = {
schema: string;
name: string;
};
70 changes: 70 additions & 0 deletions src/lib/protocol/proofs/__tests__/verifyClaim.spec.ts
Original file line number Diff line number Diff line change
@@ -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, rootHash } = 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<T>(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);
});
});
37 changes: 37 additions & 0 deletions src/lib/protocol/proofs/merkleTree.ts
Original file line number Diff line number Diff line change
@@ -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): `0x${string}`[] {
return tree.getHexProof(hashLeaf(claim)) as `0x${string}`[];
}

export function verifyMerkleClaim(tree: MerkleTree, claim: ProvableClaim, proof: string[]): boolean {
const root = tree.getRoot().toString('hex');
return tree.verify(proof, hashLeaf(claim), root);
}
68 changes: 68 additions & 0 deletions src/prisma/migrations/20241107150504_onchain_claims/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
-- AlterTable
ALTER TABLE "BuilderEvent" ADD COLUMN "weeklyClaimId" UUID;

-- AlterTable
ALTER TABLE "GemsReceipt" ADD COLUMN "onchainAttestationRevoked" BOOLEAN DEFAULT false,
ADD COLUMN "onchainAttestationUid" TEXT,
ADD COLUMN "onchainChainId" INTEGER;

-- AlterTable
ALTER TABLE "Scout" ADD COLUMN "onchainProfileAttestationChainId" INTEGER,
ADD COLUMN "onchainProfileAttestationUid" TEXT;

-- AlterTable
ALTER TABLE "ScoutGameActivity" ADD COLUMN "tokensReceiptId" UUID;

-- CreateTable
CREATE TABLE "WeeklyClaims" (
"id" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"season" TEXT NOT NULL,
"week" TEXT NOT NULL,
"merkleTreeRoot" TEXT NOT NULL,
"totalClaimable" INTEGER NOT NULL,
"claims" JSONB NOT NULL,
"proofsMap" JSONB NOT NULL,

CONSTRAINT "WeeklyClaims_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "TokensReceipt" (
"id" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"value" INTEGER NOT NULL,
"claimedAt" TIMESTAMP(3),
"eventId" UUID NOT NULL,
"recipientId" UUID,
"senderId" UUID,

CONSTRAINT "TokensReceipt_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "WeeklyClaims_week_key" ON "WeeklyClaims"("week");

-- CreateIndex
CREATE INDEX "TokensReceipt_recipientId_idx" ON "TokensReceipt"("recipientId");

-- CreateIndex
CREATE INDEX "TokensReceipt_senderId_idx" ON "TokensReceipt"("senderId");

-- CreateIndex
CREATE INDEX "TokensReceipt_eventId_idx" ON "TokensReceipt"("eventId");

-- AddForeignKey
ALTER TABLE "BuilderEvent" ADD CONSTRAINT "BuilderEvent_weeklyClaimId_fkey" FOREIGN KEY ("weeklyClaimId") REFERENCES "WeeklyClaims"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ScoutGameActivity" ADD CONSTRAINT "ScoutGameActivity_tokensReceiptId_fkey" FOREIGN KEY ("tokensReceiptId") REFERENCES "TokensReceipt"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "TokensReceipt" ADD CONSTRAINT "TokensReceipt_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "BuilderEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "TokensReceipt" ADD CONSTRAINT "TokensReceipt_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Scout"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "TokensReceipt" ADD CONSTRAINT "TokensReceipt_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "Scout"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Loading

0 comments on commit d5a3b56

Please sign in to comment.