Skip to content

feat(eas-form): auto generate form from eas schema #67

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 17 commits into
base: draft
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -83,7 +84,7 @@ const meta = {
parameters: {
layout: "centered",
},
decorators: [withToaster(), withWalletControl()],
decorators: [withToaster(), withWalletControl(), withQueryClientProvider()],
args: {},
} satisfies Meta<typeof AttestationFormEasSdk>;

Expand All @@ -109,9 +110,9 @@ export const OnchainSepolia: Story = {
args: {
isOffchain: false,
...createArgs(
SCHEMA_BY_NAME.IS_A_FRIEND,
SCHEMA_BY_NAME.VOTE,
sepolia,
SCHEMA_BY_NAME.IS_A_FRIEND.byFixture.isFriend,
SCHEMA_BY_NAME.VOTE.byFixture.vote,
),
},
decorators: [],
Expand All @@ -133,9 +134,9 @@ export const OffchainSepolia: Story = {
args: {
isOffchain: true,
...createArgs(
SCHEMA_BY_NAME.IS_A_FRIEND,
SCHEMA_BY_NAME.VOTE,
sepolia,
SCHEMA_BY_NAME.IS_A_FRIEND.byFixture.isFriend,
SCHEMA_BY_NAME.VOTE.byFixture.vote,
),
},
decorators: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ 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,
Expand Down Expand Up @@ -77,6 +80,7 @@ const meta = {
withWalletControlWagmi(),
withMockAccount(),
withWagmiProvider(),
withQueryClientProvider(),
],
args: {},
} satisfies Meta<typeof AttestationFormWagmi>;
Expand Down Expand Up @@ -110,23 +114,25 @@ 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(lets create extra stories and keep as is)

args: {
isOffchain: true,
...createArgs(
SCHEMA_BY_NAME.IS_A_FRIEND,
SCHEMA_BY_NAME.VOTE,
sepolia,
SCHEMA_BY_NAME.IS_A_FRIEND.byFixture.isFriend,
SCHEMA_BY_NAME.VOTE.byFixture.vote,
),
},
};

export const AttestationWagmiOnchain: Story = {
// @ts-expect-error withMockAccount() decorator should inject an account.
args: {
isOffchain: false,
...createArgs(
SCHEMA_BY_NAME.IS_A_FRIEND,
SCHEMA_BY_NAME.VOTE,
sepolia,
SCHEMA_BY_NAME.IS_A_FRIEND.byFixture.isFriend,
SCHEMA_BY_NAME.VOTE.byFixture.vote,
),
},
};
5 changes: 3 additions & 2 deletions packages/domain/src/user.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ const vitalik = {
// stable private key
// 0x4E123166e7DfDE7AbA29162Fb3a5c6Af562443D4
const user = {
privateKey: config.test.user.privateKey as Hex,
address: "",
privateKey:
"0x9e0fbda2334ed9a312bfb8c59bbc55f6c059a69fae1a1e818ddcd6c60843375b" as Hex,
address: "0x8178c9834DDaE72D4fcAB4655bF16bCe9C7bE557",
};

const eas = {
Expand Down
6 changes: 6 additions & 0 deletions packages/gql/src/graphql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

/**
Expand All @@ -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] ?? {};
}
26 changes: 26 additions & 0 deletions packages/gql/src/graphql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2842,3 +2842,29 @@ export const AllAttestationsByDocument = new TypedDocumentString(`
AllAttestationsByQuery,
AllAttestationsByQueryVariables
>;

export type SchemaByQueryVariables = Exact<{
where?: InputMaybe<SchemaWhereUniqueInput>;
}>;

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<SchemaByQuery, SchemaByQueryVariables>;
125 changes: 75 additions & 50 deletions packages/ui-react/src/components/attestations/attestation-form.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
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 { 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 { ToastAction } from "#components/shadcn/toast";
import { 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";
Expand All @@ -40,20 +39,11 @@ export const AttestationForm = ({
isOffchain,
signAttestation,
}: AttestationFormParams) => {
const formSchema = z.object({
recipient: z
.string()
.length(42, {
message: "address must be 42 characters.",
})
.brand<Address>(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
recipient: "0xFD50b031E778fAb33DfD2Fc3Ca66a1EeF0652165",
},
const { formSchema, form, isLoading, schemaDetails } = useEasSchemaForm({
schemaId,
chainId,
});

function onSubmit(values: z.infer<typeof formSchema>) {
signAttestation().then(({ uids, txnReceipt }: any) => {
const [uid] = uids;
Expand All @@ -80,38 +70,73 @@ export const AttestationForm = ({
return (
<Card className="pt-8">
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="recipient"
render={({ field }) => (
<FormItem>
<div className="flex gap-2 pt-4">
<AttestationSchemaBadge
chainId={chainId}
schemaId={schemaId}
schemaIndex={schemaIndex || ""}
/>
IS A FRIEND
</div>
<FormLabel>Recipient</FormLabel>
<FormControl>
<Input
placeholder="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
{...field}
/>
</FormControl>
<FormDescription>
Attest You met this person in real life
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
{isLoading ? (
<Spinner className="animate-spin" />
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-5"
>
<div className="flex justify-between items-center pt-4">
<div className="flex gap-2 items-center">
<AttestationSchemaBadge
chainId={chainId}
schemaId={schemaId}
schemaIndex={schemaDetails?.index || schemaIndex || ""}
/>
{getShortHex(schemaId as unknown as `0x${string}`)}
</div>

<div className="flex gap-2 items-center">
{!!schemaDetails?.revocable && (
<Badge variant={"destructive"}>REVOCABLE</Badge>
)}
{!!isOffchain && <Badge>OFFCHAIN</Badge>}
</div>
</div>
{Object.keys(formSchema.shape).map((schemaKey) => {
return (
<FormField
key={schemaKey}
control={form.control}
name={schemaKey}
render={({ field }) => (
<FormItem>
<FormLabel>{schemaKey}</FormLabel>
<FormControl>
<Input
{...field}
type={
formSchema.shape[schemaKey] instanceof ZodNumber
? "number"
: "text"
}
onChange={(value) =>
formSchema.shape[schemaKey] instanceof ZodNumber
? field.onChange(value.target.valueAsNumber)
: field.onChange(value.target.value)
}
placeholder={
formSchema.shape[schemaKey] instanceof ZodNumber
? "number"
: "string"
}
/>
</FormControl>
<FormMessage className="font-light" />
</FormItem>
)}
/>
);
})}

<Button type="submit" style={{ marginTop: "0.5rem" }}>
Submit
</Button>
</form>
</Form>
)}
</CardContent>
</Card>
);
Expand Down
80 changes: 80 additions & 0 deletions packages/ui-react/src/hooks/eas/use-eas-schema-form.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { getRandomAddress } from "@geist/domain/user.fixture";
import { describe, expect, it } from "vitest";
import { 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"],
});
});
});
});
Loading
Loading