Skip to content

Commit

Permalink
Adds generation of attestation proofs (#104)
Browse files Browse the repository at this point in the history
* Adds generation of attesattion proofs

* Adds dynamic proofs from attestations

* Fixes linter issues

* Fixes review comments

* Removes typo from .env.example
  • Loading branch information
sudoFerraz authored Apr 13, 2024
1 parent d64b634 commit 91113ef
Show file tree
Hide file tree
Showing 9 changed files with 517 additions and 349 deletions.
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ SOURCECRED_INSTANCE_PATH="/instance"
SOURCECRED_CACHE_DB_PATH="/instance/cache/sourcecred/github"
SENDGRID_API_KEY=REPLACE_WITH_REAL

ALCHEMY_API_KEY=""
ATTESTATION_SECRET=""
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"lint:fix": "eslint \"{src/components,src/app}/*/\" --ext=.ts,.tsx --fix"
},
"dependencies": {
"@ethereum-attestation-service/eas-sdk": "0.29.1",
"@ethereum-attestation-service/eas-sdk": "1.5.0",
"@mdx-js/loader": "^3.0.0",
"@mdx-js/react": "^3.0.0",
"@next/mdx": "^14.1.0",
Expand Down
121 changes: 121 additions & 0 deletions src/app/(dashboard)/attestation/attestationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";
import { LoadingCircle } from "@/components/navigation/loading";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useEthersSigner } from "@/lib/ethersUtils";
import axios from "axios";
import { useEffect, useState } from "react";
import { useAccount } from "wagmi";
import { createAttestation, createProofs } from "./utils/attestation-utils";
import { useSession } from "next-auth/react";

type GenerateAttestationModalProps = {
teamId: string;
};

export function GenerateAttestationModal({
...props
}: GenerateAttestationModalProps) {
const account = useAccount();
const signer = useEthersSigner();
const session = useSession();
const [userAddress, setUserAddress] = useState<string | undefined>(undefined);
const [attestationPrivateData, setAttestationPrivateData] = useState<any>();
const [attestationUuid, setAttestationUuid] = useState<string | undefined>(
undefined,
);
const [userSalt, setUserSalt] = useState<string | undefined>(undefined);
const [userLogin, setUserLogin] = useState<string | undefined>(undefined);

useEffect(() => {
if (account) {
setUserAddress(account.address);
}
}, [account]);

useEffect(() => {
if (session.data?.user?.name) {
setUserLogin(session.data.githubLogin);
}
}, [session]);

useEffect(() => {
if (
signer &&
userAddress &&
attestationPrivateData &&
userLogin &&
userSalt
) {
createAttestation({
address: "0xB5E5559C6b85e8e867405bFFf3D15f59693eBE2f",
privateData: attestationPrivateData,
signer: signer,
salt: userSalt,
}).then((attestationUuid) => {
setAttestationUuid(attestationUuid.attestationUuid);
const proof = createProofs(
attestationPrivateData,
[userLogin],
userSalt,
);
console.log(JSON.stringify(proof));
});
}
}, [signer, userAddress, attestationPrivateData, userSalt]);

const handleFetchAttestationPrivateData = async () => {
const { data } = await axios.get(
"/api/attestations?team_id=" + props.teamId,
);
if (data.success) {
setAttestationPrivateData(data.privateAttestationData);
setUserSalt(data.userSalt);
}
};

return (
<Dialog>
<DialogTrigger asChild>
<Button
className="mr-2"
disabled={!account.isConnected}
onClick={() => {
handleFetchAttestationPrivateData();
}}
>
{account.isConnected ? (
<>Create Attestation</>
) : account.isConnecting || account.isReconnecting ? (
<>Connecting...</>
) : (
<>Connect wallet to create attestation</>
)}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Loading contribution graph</DialogTitle>
<DialogDescription>
Please wait while we are generating your contribution graph
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center space-x-2 pt-6 pb-6">
<LoadingCircle></LoadingCircle>
</div>
<DialogFooter className="sm:justify-start">
<DialogClose asChild onClick={() => {}}></DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
134 changes: 134 additions & 0 deletions src/app/(dashboard)/attestation/utils/attestation-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { EAS, SchemaEncoder } from "@ethereum-attestation-service/eas-sdk";
import { JsonRpcSigner } from "ethers";
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
import {
EncodedMerkleValue,
MerkleValueWithSalt,
decodeMerkleValues,
encodeMerkleValues,
encodeValuesToMerkleTree,
} from "./merkle-utils";

export type CreateAttestationBodyDto = {
address: string;
privateData: AttestationPrivateDataDto;
signer: JsonRpcSigner;
salt: string;
};

export type AttestationPrivateDataDto = {
[key: string]: string;
};

export type ContributorDataDto = {
githubUsername: string;
rank: string;
score: string;
};

export type WeightsConfigDto = {
prReview: string;
pr: string;
};

export type AttestationUuidDto = {
attestationUuid: string;
};

export async function createAttestation({
address,
privateData,
signer,
salt,
}: CreateAttestationBodyDto): Promise<AttestationUuidDto> {
// Sepolia private data schema
// @TODO Implement a mapping to reference the correct private data schema for each network
const schemaUID =
"0x20351f973fdec1478924c89dfa533d8f872defa108d9c3c6512267d7e7e5dbc2";

const merkleTree = createMerkleTree(privateData, salt);
const merkleRoot = merkleTree.root;

const eas = await initializeEAS(signer);
const schemaEncoder = new SchemaEncoder("bytes32 privateData");
const encodedData = schemaEncoder.encodeData([
{ name: "privateData", value: merkleRoot, type: "bytes32" },
]);

const tx = await eas.attest({
schema: schemaUID,
data: {
recipient: address,
expirationTime: undefined,
revocable: false,
data: encodedData,
},
});
const attestationUuid = await tx.wait();

return { attestationUuid: attestationUuid };
}

export function createMerkleTree(
privateData: AttestationPrivateDataDto,
salt: string,
): StandardMerkleTree<EncodedMerkleValue> {
const merkleTreeValuesWithSalt: MerkleValueWithSalt[] = [];

let privateDataKey: keyof AttestationPrivateDataDto;
for (privateDataKey in privateData) {
merkleTreeValuesWithSalt.push({
type: "string",
name: privateDataKey,
value: privateData[privateDataKey],
salt: salt,
} as MerkleValueWithSalt);
}
return encodeValuesToMerkleTree(merkleTreeValuesWithSalt);
}

export function createProofs(
privateData: AttestationPrivateDataDto,
fields: string[],
salt: string,
) {
const merkleTree = createMerkleTree(privateData, salt);
let privateDataKey: keyof AttestationPrivateDataDto;
const valuesWithSalt: MerkleValueWithSalt[] = [];
for (privateDataKey in privateData) {
if (fields.includes(privateDataKey)) {
valuesWithSalt.push(
valueWithSalt(privateDataKey, privateData[privateDataKey], salt),
);
}
}
const merkleValues = encodeMerkleValues(valuesWithSalt);
const multiproof = merkleTree.getMultiProof(merkleValues);
const proofs = {
...multiproof,
leaves: decodeMerkleValues(multiproof.leaves),
};

return { proofs: proofs };
}

function valueWithSalt(
field: string,
value: string,
salt: string,
): MerkleValueWithSalt {
return {
type: "string",
name: field,
value: value,
salt,
};
}

export async function initializeEAS(signer: JsonRpcSigner): Promise<EAS> {
// Sepolia v0.26 schema contract
const easContractAddress = "0xC2679fBD37d54388Ce493F1DB75320D236e1815e";
const eas = new EAS(easContractAddress);
eas.connect(signer);
return eas;
}
106 changes: 106 additions & 0 deletions src/app/(dashboard)/attestation/utils/merkle-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { AbiCoder } from "ethers";
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";

export type FullMerkleDataTree = {
root: string;
values: MerkleValueWithSalt[];
};

export type FullMerkleProofTreeWithRelatedUid = FullMerkleDataTree & {
relatedUid: string;
};

export interface MerkleMultiProof {
leaves: Leaf[];
proof: string[];
proofFlags: boolean[];
}

export interface Leaf {
type: string;
name: string;
value: any;
salt: string;
}

export type PartialMerkleProof = {
/** Hash of the partial proof */
id: string;
root: string;
proof: MerkleMultiProof;
relatedUid: string;
};

export interface MerkleValue {
type: string;
name: string;
value: any;
}

export type MerkleValueWithSalt = MerkleValue & { salt: string };

export type EncodedMerkleValue = [string, string, string, string];

export const merkleValueAbiEncoding: EncodedMerkleValue = [
"string",
"string",
"bytes",
"bytes32",
];

export function encodeValuesToMerkleTree(
valuesWithSalt: MerkleValueWithSalt[],
) {
const encodedValues = encodeMerkleValues(valuesWithSalt);

return StandardMerkleTree.of(encodedValues, merkleValueAbiEncoding);
}

export function encodeMerkleValues(
inValues: MerkleValueWithSalt[],
): EncodedMerkleValue[] {
const abiCoder = new AbiCoder();
return inValues.map((v) => [
v.type,
v.name,
abiCoder.encode([v.type], [v.value]),
v.salt,
]);
}

export function verifyFullMerkleTree(tree: FullMerkleDataTree): string {
const encodedValues = encodeMerkleValues(tree.values);
const merkleTree = StandardMerkleTree.of(
encodedValues,
merkleValueAbiEncoding,
);
return merkleTree.root;
}

export function decodeMerkleValues(
inValues: EncodedMerkleValue[],
): MerkleValueWithSalt[] {
const abiCoder = new AbiCoder();
return inValues.map((v) => ({
type: v[0],
name: v[1],
value: abiCoder.decode([v[0]], v[2])[0],
salt: v[3],
}));
}

export function verifyMultiProof(
root: string,
proof: MerkleMultiProof,
): boolean {
const multiproof = {
...proof,
leaves: encodeMerkleValues(proof.leaves),
};

return StandardMerkleTree.verifyMultiProof(
root,
merkleValueAbiEncoding,
multiproof,
);
}
Loading

0 comments on commit 91113ef

Please sign in to comment.