diff --git a/apps/storybook/src/stories/attestations/AttestationFormEasSdk.stories.tsx b/apps/storybook/src/stories/attestations/AttestationFormEasSdk.stories.tsx index 826f6055..40283830 100644 --- a/apps/storybook/src/stories/attestations/AttestationFormEasSdk.stories.tsx +++ b/apps/storybook/src/stories/attestations/AttestationFormEasSdk.stories.tsx @@ -10,6 +10,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { encodeBytes32String } from "ethers"; import { Chain, Hex, zeroHash } from "viem"; import { mainnet, optimism, optimismSepolia, sepolia } from "viem/chains"; +import { withQueryClientProvider } from "#stories/decorators/wagmi.tsx"; import { withToaster } from "../decorators/toaster"; import { withWalletControl } from "../decorators/wallet-control"; @@ -59,17 +60,17 @@ const AttestationFormEasSdk = ({ schemaId={schemaId} schemaIndex={schemaIndex} isOffchain={isOffchain} - signAttestation={async (): Promise => { + signAttestation={async (values: any): Promise => { // TODO fix encode data structure const now = BigInt(Date.now()); - const { recipient, revocable, expirationTime, refUID, data, salt } = + const { recipient, revocable, expirationTime, refUID, salt } = requestTemplate; return signAttestation({ ...requestTemplate, recipient, - data, + data: values, time: now, }); }} @@ -83,7 +84,7 @@ const meta = { parameters: { layout: "centered", }, - decorators: [withToaster(), withWalletControl()], + decorators: [withToaster(), withWalletControl(), withQueryClientProvider()], args: {}, } satisfies Meta; @@ -94,6 +95,7 @@ const createArgs = (schema: any, chain: Chain, fixture: any) => { const { schemaString, byChain } = schema; const { uid, index } = byChain[chain.id]; const { data, attestData } = fixture; + return { chainId: chain.id, privateKey: BY_USER.user.privateKey, @@ -114,7 +116,6 @@ export const OnchainSepolia: Story = { SCHEMA_BY_NAME.IS_A_FRIEND.byFixture.isFriend, ), }, - decorators: [], }; export const OnchainOptimismSepolia: Story = { @@ -126,7 +127,6 @@ export const OnchainOptimismSepolia: Story = { SCHEMA_BY_NAME.IS_A_FRIEND.byFixture.isFriend, ), }, - decorators: [], }; export const OffchainSepolia: Story = { @@ -142,11 +142,13 @@ export const OffchainSepolia: Story = { }; // Until dynamic form -// export const OffchainVote: Story = { -// args: { -// isOffchain: true, -// ...createArgs(SCHEMA_BY_NAME.VOTE, sepolia.id), -// ...SCHEMA_BY_NAME.VOTE.byFixture.vote, -// }, -// decorators: [], -// }; +export const OffchainVote: Story = { + args: { + isOffchain: true, + ...createArgs( + SCHEMA_BY_NAME.IS_A_FRIEND, + sepolia, + SCHEMA_BY_NAME.IS_A_FRIEND.byFixture.isFriend, + ), + }, +}; diff --git a/apps/storybook/src/stories/attestations/AttestationFormWagmi.stories.tsx b/apps/storybook/src/stories/attestations/AttestationFormWagmi.stories.tsx index 8553ec95..8769e6f4 100644 --- a/apps/storybook/src/stories/attestations/AttestationFormWagmi.stories.tsx +++ b/apps/storybook/src/stories/attestations/AttestationFormWagmi.stories.tsx @@ -9,18 +9,20 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Account, Address, Chain, Hex, stringToHex, zeroHash } from "viem"; import { sepolia } from "viem/chains"; import { withToaster } from "../decorators/toaster"; -import { withMockAccount, withWagmiProvider } from "../decorators/wagmi"; +import { + withMockAccount, + withQueryClientProvider, + withWagmiProvider, +} from "../decorators/wagmi"; import { withWalletControlWagmi } from "../decorators/wallet-control"; const AttestationFormWagmi = ({ schemaId, schemaIndex, - account, isOffchain, schemaString, chain, - data, attestData, }: { schemaId: string; @@ -29,7 +31,6 @@ const AttestationFormWagmi = ({ isOffchain: boolean; schemaString: string; chain: Chain; - data: any; attestData: Omit; }) => { if (!account) { @@ -54,10 +55,10 @@ const AttestationFormWagmi = ({ schemaId={schemaId} schemaIndex={schemaIndex} isOffchain={isOffchain} - signAttestation={async () => + signAttestation={async (values: any) => signAttestation({ ...attestData, - data, + data: values, recipient, // attester: account.address, }) @@ -77,6 +78,7 @@ const meta = { withWalletControlWagmi(), withMockAccount(), withWagmiProvider(), + withQueryClientProvider(), ], args: {}, } satisfies Meta; @@ -110,6 +112,7 @@ const createArgs = (schema: any, chain: Chain, fixture: any) => { // TODO chain control at withWalletControlWagmi export const AttestationWagmiOffchain: Story = { + // @ts-expect-error withMockAccount() decorator should inject an account. args: { isOffchain: true, ...createArgs( @@ -121,6 +124,7 @@ export const AttestationWagmiOffchain: Story = { }; export const AttestationWagmiOnchain: Story = { + // @ts-expect-error withMockAccount() decorator should inject an account. args: { isOffchain: false, ...createArgs( diff --git a/packages/domain/src/user.fixture.ts b/packages/domain/src/user.fixture.ts index 943bb670..399acc66 100644 --- a/packages/domain/src/user.fixture.ts +++ b/packages/domain/src/user.fixture.ts @@ -12,7 +12,7 @@ const vitalik = { // 0x4E123166e7DfDE7AbA29162Fb3a5c6Af562443D4 const user = { privateKey: config.test.user.privateKey as Hex, - address: "", + address: "0x4E123166e7DfDE7AbA29162Fb3a5c6Af562443D4", }; const eas = { diff --git a/packages/gql/src/graphql/gql.ts b/packages/gql/src/graphql/gql.ts index dd8ddc33..ec572d6e 100644 --- a/packages/gql/src/graphql/gql.ts +++ b/packages/gql/src/graphql/gql.ts @@ -14,6 +14,8 @@ import * as types from "./graphql"; const documents = { "\n query allAttestationsBy(\n $where: AttestationWhereInput\n ) {\n attestations(where: $where) {\n id\n txid\n recipient\n schema {\n index\n schemaNames {\n name\n }\n }\n time\n isOffchain\n schemaId\n attester\n }\n }\n": types.AllAttestationsByDocument, + "\n\tquery schemaBy($where: SchemaWhereUniqueInput!) {\n\t\tschema(where: $where) {\n\t\t\tschemaString: schema\n\t\t\tindex\n\t\t\trevocable\n\t\t\tcreator\n\t\t}\n\t}\n": + types.SchemaByDocument, }; /** @@ -23,6 +25,10 @@ export function gql( source: "\n query allAttestationsBy(\n $where: AttestationWhereInput\n ) {\n attestations(where: $where) {\n id\n txid\n recipient\n schema {\n index\n schemaNames {\n name\n }\n }\n time\n isOffchain\n schemaId\n attester\n }\n }\n", ): typeof import("./graphql").AllAttestationsByDocument; +export function gql( + source: "\n\tquery schemaBy($where: SchemaWhereUniqueInput!) {\n\t\tschema(where: $where) {\n\t\t\tschemaString: schema\n\t\t\tindex\n\t\t\trevocable\n\t\t\tcreator\n\t\t}\n\t}\n", +): typeof import("./graphql").SchemaByDocument; + export function gql(source: string) { return (documents as any)[source] ?? {}; } diff --git a/packages/gql/src/graphql/graphql.ts b/packages/gql/src/graphql/graphql.ts index a3eb7161..fe294d69 100644 --- a/packages/gql/src/graphql/graphql.ts +++ b/packages/gql/src/graphql/graphql.ts @@ -2842,3 +2842,29 @@ export const AllAttestationsByDocument = new TypedDocumentString(` AllAttestationsByQuery, AllAttestationsByQueryVariables >; + +export type SchemaByQueryVariables = Exact<{ + where?: InputMaybe; +}>; + +export type SchemaByQuery = { + __typename?: "Query"; + schema: { + __typename?: "Schema"; + index: string; + schemaString: string; + revocable: boolean; + creator: string; + }; +}; + +export const SchemaByDocument = new TypedDocumentString(` + query schemaBy($where: SchemaWhereUniqueInput!) { + schema(where: $where) { + schemaString: schema + index + revocable + creator + } + } +`) as unknown as TypedDocumentString; diff --git a/packages/ui-react/src/components/attestations/attestation-form.tsx b/packages/ui-react/src/components/attestations/attestation-form.tsx index 1f1c58b2..10352fae 100644 --- a/packages/ui-react/src/components/attestations/attestation-form.tsx +++ b/packages/ui-react/src/components/attestations/attestation-form.tsx @@ -1,38 +1,36 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { Address } from "viem"; -import { z } from "zod"; +import { Spinner } from "@radix-ui/themes"; +import { ZodBoolean, ZodNumber, z } from "zod"; +import { Badge } from "#components/shadcn/badge"; import { Button } from "#components/shadcn/button"; import { Card, CardContent } from "#components/shadcn/card"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "#components/shadcn/form"; import { Input } from "#components/shadcn/input"; +import { Switch } from "#components/shadcn/switch"; import { ToastAction } from "#components/shadcn/toast"; +import { + getSubmissionData, + useEasSchemaForm, +} from "#hooks/eas/use-eas-schema-form"; import { toast } from "#hooks/shadcn/use-toast"; import { getEasscanAttestationUrl } from "#lib/eas/easscan"; import { getShortHex } from "#lib/utils/hex"; import { AttestationSchemaBadge } from "./attestation-schema-badge"; -// TODO dynamic enough to generate fields -// now focus on sdk part - export interface AttestationFormParams { chainId: number; schemaId: string; schemaIndex?: string; isOffchain: boolean; - signAttestation: () => Promise; + signAttestation: (values: any) => Promise; } -// TODO dynamic schema. For now, hardcode the MetIRL -// https://github.com/fractaldotbox/geist-dapp-kit/issues/56 export const AttestationForm = ({ chainId, schemaId, @@ -40,78 +38,122 @@ export const AttestationForm = ({ isOffchain, signAttestation, }: AttestationFormParams) => { - const formSchema = z.object({ - recipient: z - .string() - .length(42, { - message: "address must be 42 characters.", - }) - .brand
(), - }); - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - recipient: "0xFD50b031E778fAb33DfD2Fc3Ca66a1EeF0652165", - }, + const { formSchema, form, isLoading, schemaDetails } = useEasSchemaForm({ + schemaId, + chainId, }); + function onSubmit(values: z.infer) { - signAttestation().then(({ uids, txnReceipt }: any) => { - const [uid] = uids; - const url = getEasscanAttestationUrl(chainId, uid, isOffchain); + if (!!schemaDetails) + signAttestation( + getSubmissionData(schemaDetails.schemaString, values), + ).then(({ uids, txnReceipt }: any) => { + console.log({ uids, txnReceipt }); + const [uid] = uids; + const url = getEasscanAttestationUrl(chainId, uid, isOffchain); - const description = isOffchain - ? getShortHex(uid) - : `attested ${txnReceipt?.transactionHash}`; + const description = isOffchain + ? getShortHex(uid) + : `attested ${txnReceipt?.transactionHash}`; - toast({ - title: "Attestation success", - description, - action: ( - - - View on EASSCAN - - - ), + toast({ + title: "Attestation success", + description, + action: ( + + + View on EASSCAN + + + ), + }); }); - }); } + // TODO: array schema handling return ( -
- - ( - -
- - IS A FRIEND -
- Recipient - - - - - Attest You met this person in real life - - -
- )} - /> - - - + {isLoading ? ( + + ) : ( +
+ +
+
+ + {getShortHex(schemaId as unknown as `0x${string}`)} +
+ +
+ {!!schemaDetails?.revocable && ( + REVOCABLE + )} + {!!isOffchain && OFFCHAIN} +
+
+ {Object.keys(formSchema.shape).map((schemaKey) => { + return ( + ( + + {schemaKey} + {formSchema.shape[schemaKey] instanceof ZodBoolean ? ( + + + + ) : ( + + { + const value = + formSchema.shape[schemaKey] instanceof + ZodNumber + ? e.target.valueAsNumber || 0 + : e.target.value; + field.onChange(value); + }} + placeholder={ + formSchema.shape[schemaKey] instanceof ZodNumber + ? "number" + : "string" + } + /> + + )} + + + )} + /> + ); + })} + + + + + )}
); diff --git a/packages/ui-react/src/components/shadcn/switch.tsx b/packages/ui-react/src/components/shadcn/switch.tsx new file mode 100644 index 00000000..ffa4c45a --- /dev/null +++ b/packages/ui-react/src/components/shadcn/switch.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import * as React from "react"; + +import { cn } from "#lib/shadcn/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/packages/ui-react/src/hooks/eas/use-eas-schema-form.test.ts b/packages/ui-react/src/hooks/eas/use-eas-schema-form.test.ts new file mode 100644 index 00000000..869ceeba --- /dev/null +++ b/packages/ui-react/src/hooks/eas/use-eas-schema-form.test.ts @@ -0,0 +1,104 @@ +import { getRandomAddress } from "@geist/domain/user.fixture"; +import { describe, expect, it } from "vitest"; +import { + getSubmissionData, + getZodSchemaFromSchemaString, +} from "./use-eas-schema-form"; + +describe("use-schema-eas-form", () => { + describe("#getZodSchemaFromSchemaString", () => { + it("should correctly produce zod schema", () => { + const sampleEasSchema = getZodSchemaFromSchemaString( + "address walletAddress,string requestId,bool hasClaimedNFT,string message", + ); + + const randomAddress = getRandomAddress(); + + expect( + sampleEasSchema.parse({ + walletAddress: randomAddress, + requestId: "123", + hasClaimedNFT: true, + message: "hello world", + }), + ).toEqual({ + walletAddress: randomAddress, + requestId: "123", + hasClaimedNFT: true, + message: "hello world", + }); + + expect(() => + sampleEasSchema.parse({ + walletAddress: randomAddress, + requestId: "123", + hasClaimedNFT: true, + }), + ).toThrow(); + + expect(() => + sampleEasSchema.parse({ + walletAddress: randomAddress, + requestId: "123", + hasClaimedNFT: "true", + message: "hello world", + }), + ).toThrow(); + }); + + it("should correctly produce zod schema with array", () => { + const sampleEasSchema = getZodSchemaFromSchemaString( + "address walletAddress,string requestId,bool hasClaimedNFT,string message,string[] characters", + ); + + const randomAddress = getRandomAddress(); + + expect(() => + sampleEasSchema.parse({ + walletAddress: randomAddress, + requestId: "123", + hasClaimedNFT: true, + message: "hello world", + characters: "vitalik", + }), + ).toThrow(); + + expect( + sampleEasSchema.parse({ + walletAddress: randomAddress, + requestId: "123", + hasClaimedNFT: true, + message: "hello world", + characters: ["tony", "alice", "vitalik"], + }), + ).toEqual({ + walletAddress: randomAddress, + requestId: "123", + hasClaimedNFT: true, + message: "hello world", + characters: ["tony", "alice", "vitalik"], + }); + }); + }); + + describe("#getSubmissionData", () => { + it("happy flow", () => { + const data = getSubmissionData( + "address walletAddress,string requestId,bool hasClaimedNFT,string message", + { + walletAddress: "0x123", + requestId: "abcdef", + hasClaimedNFT: false, + message: "test", + }, + ); + + expect(data).toEqual([ + { name: "walletAddress", value: "0x123", type: "address" }, + { name: "requestId", value: "abcdef", type: "string" }, + { name: "hasClaimedNFT", value: false, type: "bool" }, + { name: "message", value: "test", type: "string" }, + ]); + }); + }); +}); diff --git a/packages/ui-react/src/hooks/eas/use-eas-schema-form.ts b/packages/ui-react/src/hooks/eas/use-eas-schema-form.ts new file mode 100644 index 00000000..fc24f50b --- /dev/null +++ b/packages/ui-react/src/hooks/eas/use-eas-schema-form.ts @@ -0,0 +1,152 @@ +import { gql } from "@geist/graphql"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { rawRequest } from "graphql-request"; +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { Address } from "viem"; +import { ZodArray, ZodBoolean, ZodNumber, z } from "zod"; +import { getEasscanEndpoint } from "#lib/eas/easscan"; + +type SchemaByQuery = any; + +// function to get JSON from attestation (we cannot dynamically create zod schemas) +export const getZodSchemaFromSchemaString = (schemaString: string) => { + const stringPairs = schemaString.split(","); + const entries = stringPairs.map((pair) => pair.split(" ")); + const zodSchemaObject: any = {}; + + for (const entry of entries) { + const [type = "", name = ""] = entry; + + // process the type + let zodSchemaType: any = z.string(); + if (type === "bool") { + zodSchemaType = z.boolean(); + } else if (type.includes("uint")) { + zodSchemaType = z.number(); + } + + // if we see array, we define as array schema + if (type.includes("[]")) { + zodSchemaType = zodSchemaType.array(); + } + + // attach to object + zodSchemaObject[name] = zodSchemaType; + } + + return z.object(zodSchemaObject); +}; + +// function to get submission data that is attestation-ready +export const getSubmissionData = (schemaString: string, values: any) => { + const stringPairs = schemaString.split(","); + const entries = stringPairs.map((pair) => pair.split(" ")); + + const result: { + name: string; + value: string | boolean | number | bigint; + type: string; + }[] = []; + + for (const entry of entries) { + const [type = "", name = ""] = entry; + + const resultEntry = { + name, + value: values[name], + type, + }; + + result.push(resultEntry); + } + + return result; +}; + +const schemaByQuery = gql(` + query schemaBy($where: SchemaWhereUniqueInput!) { + schema(where: $where) { + schemaString: schema + index + revocable + creator + } + } +`); + +// do we want to make it safe? +export function useEasSchemaForm({ + schemaString, + schemaId, + chainId = 1, + isEnabled = true, // whether to use this hook or not +}: { + schemaString?: string; + schemaId?: string; + isEnabled?: boolean; + chainId?: number; +}) { + if (!schemaString && !schemaId) + throw new Error( + "[useEasSchemaForm] at least one of schemaString and schemaId must be present", + ); + + const schemaQueryResults = useQuery({ + queryKey: ["schemaBy", schemaId], + queryFn: async () => { + const { data } = await rawRequest( + `${getEasscanEndpoint(chainId)}/graphql`, + schemaByQuery.toString(), + { + where: { + id: schemaId, + }, + }, + ); + + return data.schema as { + schemaString: string; + index: string; + revocable: true; + creator: Address; + }; + }, + enabled: !!isEnabled && !!schemaId, + }); + + const formSchema = useMemo(() => { + if (!!schemaQueryResults.data?.schemaString) + return getZodSchemaFromSchemaString(schemaQueryResults.data.schemaString); + + if (!!schemaString) return getZodSchemaFromSchemaString(schemaString); + + return z.object({}); + }, [schemaQueryResults?.data?.schemaString, schemaString]); + + // TODO: array schema handling + const form = useForm>({ + resolver: zodResolver(formSchema), + disabled: !isEnabled, + defaultValues: Object.fromEntries( + Object.keys(formSchema.shape).map((key) => { + let defaultValue: string | number | boolean | any[] = ""; + if (formSchema.shape[key] instanceof ZodNumber) defaultValue = 0; + if (formSchema.shape[key] instanceof ZodBoolean) defaultValue = false; + if (formSchema.shape[key] instanceof ZodArray) defaultValue = []; + return [key, defaultValue]; + }), + ), + }); + + const isLoading = schemaQueryResults.isLoading; + + // we return a react hook form instance + return { + form, + formSchema, + isLoading, + schemaDetails: schemaQueryResults.data, + }; +}