UNPKG

catts-sdk

Version:

Facilitates the local development of C-ATTS recipes.

369 lines (314 loc) 9.57 kB
import { SchemaEncoder, SchemaItem, } from "@ethereum-attestation-service/eas-sdk"; import { solidityPackedKeccak256 } from "ethers"; import { z } from "zod"; // SDK requests are proxied through the cloudflare caching worker to ensure // consistent results with the smart contract canister const CATTS_GQL_PROXY_URL = "https://query.catts.run"; /** * Zod schema for query variables. Defines the shape of query variables to be used * in GraphQL queries. */ export const queryVariablesSchema: z.ZodTypeAny = z.lazy(() => z.record(z.union([queryVariablesSchema, z.string(), z.number()])) ); /** * Defines the shape of query variables to be used in GraphQL queries. */ export type QueryVariables = z.infer<typeof queryVariablesSchema>; // Custom validation function for the name const validateRecipeName = (val: string) => { if (val.startsWith("-") || val.endsWith("-")) { return false; } if (/^\d/.test(val)) { return false; } return /^[a-z0-9-]+$/.test(val); }; // Custom validation function for keywords const validateKeyword = (keyword: string) => { return /^[a-z0-9-]+$/.test(keyword); }; const toString = z.preprocess((input) => { if (typeof input === "object") { return JSON.stringify(input); } throw new Error("Expected 'variables' to be an object"); }, z.string()); /** * Zod schema for query variables. Defines the components of a GraphQL query, * including endpoint and variables. */ export const querySchema = z .object({ url: z .string() .min(1, { message: "Endpoint must be at least 1 character long" }) .max(255, { message: "Endpoint must be at most 255 characters long" }), filter: z.string().optional().nullable(), headers: z.any().optional().nullable(), body: z .object({ query: z .string() .min(1, { message: "Query must be at least 1 character long" }) .max(1024, { message: "Query must be at most 1024 characters long" }), variables: z.any(), }) .optional() .nullable(), }) .strict(); /** * Defines the components of a GraphQL query, including endpoint and variables. */ export type Query = z.infer<typeof querySchema>; /** * Zod schema for an array of queries. */ export const queriesSchema = z.array(querySchema); /** * An array of queries. */ export type Queries = z.infer<typeof queriesSchema>; /** * Zod schema for a recipe. Defines the structure of a recipe, including queries * and output schema. */ export const recipeSchema = z .object({ // Name validation name: z .string() .min(3, { message: "Name must be at least 3 characters long" }) .max(50, { message: "Name must be at most 50 characters long" }) .refine((val) => validateRecipeName(val), { message: "Name must be lowercase, alphanumeric, may contain hyphens, must not start or end with a hyphen, and must not start with a digit", }), // Description validation description: z .string() .min(3, { message: "Description must be at least 3 characters long" }) .max(160, { message: "Description must be at most 160 characters long" }) .optional(), // Keywords validation keywords: z .array( z .string() .min(3, { message: "Each keyword must be at least 3 characters long", }) .max(50, { message: "Each keyword must be at most 50 characters long", }) .refine((keyword) => validateKeyword(keyword), { message: "Each keyword must be lowercase, alphanumeric, and may contain hyphens", }) ) .optional() .refine((keywords) => keywords && keywords.length > 0, { message: "Keywords must not be empty", path: ["keywords"], // Specify the path for the error message }), // Queries validation queries: z.array(querySchema), // Schema validation (length constraints) schema: z .string() .min(1, { message: "Schema must be at least 1 character long" }) .max(512, { message: "Schema must be at most 512 characters long" }), // Resolver validation (length constraint, exactly 42 characters) resolver: z .string() .length(42, { message: "Resolver must be exactly 42 characters long" }), // Revokable validation revokable: z.boolean().refine((val) => val === false, { message: "'revokable' should be false, revokable attestations are not yet supported", }), }) .strict(); /** * Defines the structure of a recipe, including queries and output schema. */ export type Recipe = z.infer<typeof recipeSchema>; // Define the basic schema value types const schemaValueBase = z.union([ z.string(), z.boolean(), z.number(), z.bigint(), ]); // Define the more complex schema value types const schemaValueComplex = z.union([ z.record(z.unknown()), // Record<string, unknown> z.array(z.record(z.unknown())), // Record<string, unknown>[] z.array(z.unknown()), // unknown[] ]); // Combine the base and complex schemas into a single SchemaValue schema const SchemaValue = z.union([schemaValueBase, schemaValueComplex]); // Define the SchemaItem schema const SchemaItem = z .object({ name: z.string(), type: z.string(), value: SchemaValue, }) .strict(); export function parseRecipe(input: unknown): Recipe { return recipeSchema.parse(input); } function substitutePlaceholders(args: FetchQueryArgs): Query { const placeholders: { [key: string]: string } = { "{user_eth_address}": args.placeHolderValues?.userEthAddress || "0x0000000000000000000000000000000000000000", "{user_eth_address_lowercase}": args.placeHolderValues?.userEthAddress?.toLowerCase() || "0x0000000000000000000000000000000000000000", }; let url = args.query.url; for (const [key, value] of Object.entries(placeholders)) { url = url.split(key).join(value); } let query = { ...args.query, url, }; if (args.query.filter) { let filter = args.query.filter; for (const [key, value] of Object.entries(placeholders)) { filter = filter.split(key).join(value); } query = { ...query, filter, }; } if (args.query.headers) { let headers = JSON.stringify(args.query.headers); for (const [key, value] of Object.entries(placeholders)) { headers = headers.split(key).join(value); } query = { ...query, headers: JSON.parse(headers), }; } if (args.query.body?.variables) { let variables = JSON.stringify(args.query.body.variables); for (const [key, value] of Object.entries(placeholders)) { variables = variables.split(key).join(value); } query = { ...query, body: { ...args.query.body, variables: JSON.parse(variables), }, }; } return query; } type FetchQueryArgs = { query: Query; cacheKey?: string; placeHolderValues?: { userEthAddress?: string; }; proxyUrl?: string; verbose?: boolean; }; /** * Fetches the result of a query from the GraphQl endpoint specified in the query. * * @returns The result of the query in JSON format. */ export async function fetchQuery(args: FetchQueryArgs) { const query = substitutePlaceholders(args); let proxyUrl = args?.proxyUrl || CATTS_GQL_PROXY_URL; const cacheKey = args?.cacheKey || Math.random().toString(36).substring(2, 15); proxyUrl = `${proxyUrl}/${cacheKey}`; const options = { method: "POST", body: JSON.stringify(query), }; if (args?.verbose) { console.log("Query:", query); } const response = await fetch(proxyUrl, options); if (args?.verbose) { console.log("Response status:", response.status); } if (!response.ok) { throw new Error(`Failed to fetch query from ${proxyUrl}`); } return response.json(); } /** * Validates that the processor result matches the output schema specified in the recipe. * * @param processorResult The raw result of the processor script as a string. * * @returns The result of the processor script in JSON format. On success, the output should match outputSchema specified in the recipe. */ export async function validateProcessorResult({ processorResult, }: { processorResult: string; }): Promise<SchemaItem[]> { if (typeof processorResult !== "string") { throw new Error("Processor result must be a string"); } const json = JSON.parse(processorResult); if (!Array.isArray(json)) { throw new Error("Processor result must be an array"); } if (json.length === 0) { throw new Error("Processor returned an empty array"); } let schemaItems: SchemaItem[]; try { schemaItems = json.map((item: any) => SchemaItem.parse(item)); } catch (error) { throw new Error("Invalid processor result"); } return schemaItems; } /** * Validates that an array of schema items matches what is expected by the schema. */ export async function validateSchemaItems({ schemaItems, schema, }: { schemaItems: SchemaItem[]; schema: string; }) { const schemaEncoder = new SchemaEncoder(schema); return schemaEncoder.encodeData(schemaItems); } /** * An EAS schema UID is a hash of the schema, resolver and revokable flag. */ export function getSchemaUid({ schema, resolver, revokable, }: { schema: string; resolver: string; revokable: boolean; }) { return solidityPackedKeccak256( ["string", "address", "bool"], [schema, resolver, revokable] ); }