From 3838b636d0c894fc74e872a57049b5d09442419d Mon Sep 17 00:00:00 2001 From: Pi Lanningham Date: Fri, 21 Feb 2025 12:43:53 -0500 Subject: [PATCH] Add typings for blueprint code (#250) * Make blueprints typesafe * Cleanup code generation The code generation should now be a bit more readable, and output nicer formatted generated code * fmt and lint --- packages/blaze-blueprint/package.json | 1 + packages/blaze-blueprint/src/blueprint.ts | 445 ++++++++++++++-------- packages/blaze-blueprint/src/data.ts | 20 + packages/blaze-blueprint/src/schema.ts | 50 +++ packages/blaze-blueprint/src/shared.ts | 2 + packages/blaze-tsconfig/base.json | 2 +- packages/blaze-tx/src/data.ts | 1 + 7 files changed, 353 insertions(+), 168 deletions(-) create mode 100644 packages/blaze-blueprint/src/data.ts create mode 100644 packages/blaze-blueprint/src/schema.ts create mode 100644 packages/blaze-blueprint/src/shared.ts diff --git a/packages/blaze-blueprint/package.json b/packages/blaze-blueprint/package.json index e533a73..966c403 100644 --- a/packages/blaze-blueprint/package.json +++ b/packages/blaze-blueprint/package.json @@ -16,6 +16,7 @@ "scripts": { "build": "tsup src/index.ts src/cli.ts --format esm,cjs --dts", "dev": "tsup src/index.ts --format esm,cjs --watch --dts", + "dev:cli": "tsup src/index.ts src/cli.ts --format esm,cjs --watch --dts", "lint": "eslint .", "test": "cd test && tsx blueprint_generate.ts && cd .. && jest", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", diff --git a/packages/blaze-blueprint/src/blueprint.ts b/packages/blaze-blueprint/src/blueprint.ts index f87d7d3..0d851ff 100644 --- a/packages/blaze-blueprint/src/blueprint.ts +++ b/packages/blaze-blueprint/src/blueprint.ts @@ -1,4 +1,7 @@ import * as fs from "fs/promises"; +import type { Schema, Unit } from "./schema"; +import type { Annotated, Declaration } from "./shared"; +import type { Constructor, Data } from "./data"; type Blueprint = { preamble: { @@ -8,38 +11,22 @@ type Blueprint = { plutusVersion: "v3" | "v2" | "v1"; license: string; }; - validators: { - title: string; - datum?: { - title: string; - schema: { - $ref: string; - }; - }; - redeemer: { - title: string; - schema: { - $ref: string; - }; - }; - parameters?: { - title: string; - schema: { - $ref: string; - }; - }[]; - compiledCode: string; - hash: string; - }[]; - definitions: Record< - string, - { - title: string; - schema: { - $ref: string; - }; - } - >; + validators: Validator[]; + definitions: Record>; +}; + +type Validator = { + title: string; + datum?: Parameter; + redeemer: Parameter; + parameters?: Parameter[]; + compiledCode: string; + hash: string; +}; + +type Parameter = { + title: string; + schema: Declaration; }; class Generator { @@ -51,6 +38,8 @@ import { applyParamsToScript, cborToScript } from "@blaze-cardano/uplc";`; pdataImport = `import { type PlutusData } from "@blaze-cardano/core";`; + refTypeNames = new Map(); + useSDK() { this.imports = `// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck @@ -62,162 +51,226 @@ const Script = Core.Script;`; const PlutusData = Core.PlutusData;`; } - static resolveSchema(schema: any, definitions: any, refName?: string): any { - if (schema.items) { + static inlineSchema( + schema: Declaration | Annotated, + definitions: Record>, + stack: string[] = [], + ): Schema | Annotated { + if ("items" in schema) { if (schema.items instanceof Array) { + const items: Schema[] = schema.items; return { ...schema, - items: schema.items.map((item: any) => - this.resolveSchema(item, definitions, refName), + items: items.map((item) => + this.inlineSchema(item, definitions, stack), ), }; } else { return { ...schema, - items: this.resolveSchema(schema.items, definitions, refName), + items: this.inlineSchema(schema.items, definitions, stack), }; } - } else if (schema.anyOf) { + } else if ("anyOf" in schema) { return { ...schema, - anyOf: schema.anyOf.map((a: any) => ({ + anyOf: schema.anyOf.map((a) => ({ ...a, - fields: a.fields.map((field: any) => ({ - ...this.resolveSchema(field, definitions, refName), + fields: a.fields.map((field) => ({ + ...this.inlineSchema(field, definitions, stack), title: field.title ? Generator.snakeToCamel(field.title) : undefined, })), })), }; - } else if (schema.keys && schema.values) { + } else if ("keys" in schema && "values" in schema) { return { ...schema, - keys: this.resolveSchema(schema.keys, definitions, refName), - values: this.resolveSchema(schema.values, definitions, refName), + keys: this.inlineSchema(schema.keys, definitions, stack), + values: this.inlineSchema(schema.values, definitions, stack), }; } else { - if (schema["$ref"]) { - const refKey = schema["$ref"] + if ("$ref" in schema) { + const refKey = schema.$ref .replaceAll("~1", "/") .split("#/definitions/")[1]; - if (refKey === refName) { - return schema; - } else { - refName = refKey; - const resolved = this.resolveSchema( - definitions[refKey], - definitions, - refName, + if (!refKey) { + throw new Error(`Schema has an undefined or empty string $ref`); + } + if (definitions[refKey] === undefined) { + throw new Error( + `Schema refers to ${schema.$ref}, which doesn't appear in the definitions`, + ); + } + if (stack.includes(refKey)) { + console.warn( + `Schema ${refKey} is recursive, which isn't supported quite yet; buttoming out with any.`, ); - return resolved; + return `any`; } + + const resolved = this.inlineSchema(definitions[refKey], definitions, [ + refKey, + ...stack, + ]); + return resolved; } else { return schema; } } } - schemaToType(schema: any): string { + schemaToType( + schema: + | Declaration + | Annotated> + | Data + | Annotated + | Annotated, + definitions: Record>, + ): string { if (!schema) throw new Error("Could not generate type."); - const shapeType = (schema.anyOf ? "enum" : "") || schema.dataType; - switch (shapeType) { - case "integer": { - return "bigint"; - } - case "bytes": { - return "string"; - } - case "constructor": { - if (Generator.isVoid(schema)) { - return "undefined"; - } else { - return `{${schema.fields - .map( - (field: any) => - `${field.title || "wrapper"}:${this.schemaToType(field)}`, - ) - .join(";")}}`; + if ("dataType" in schema) { + switch (schema.dataType) { + case "integer": { + return "bigint"; } - } - case "enum": { - // When enum has only one entry it's a single constructor/record object - if (schema.anyOf.length === 1) { - return this.schemaToType(schema.anyOf[0]); + case "bytes": { + return "string"; } - if (Generator.isBoolean(schema)) { - return "boolean"; + case "constructor": { + if (Generator.isVoid(schema)) { + return "undefined"; + } else { + return `{${schema.fields + .map( + (field) => + `${field.title || "wrapper"}: ${this.schemaToType(field, definitions)}`, + ) + .join(";")}}`; + } } - if (Generator.isNullable(schema)) { - return `${this.schemaToType(schema.anyOf[0].fields[0])} | null`; + case "list": { + if (schema.items instanceof Array) { + const items: Schema[] = schema.items; + const itemTypes = items.map((item) => + this.schemaToType(item, definitions), + ); + return `[${itemTypes.join(", ")}]`; + } else { + return `Array<${this.schemaToType(schema.items, definitions)}>`; + } } - return schema.anyOf - .map((entry: any) => - entry.fields.length === 0 - ? `"${entry.title}"` - : `{${entry.title}: ${ - entry.fields[0].title - ? `{${entry.fields - .map((field: any) => - [field.title, this.schemaToType(field)].join(":"), - ) - .join(",")}}}` - : `[${entry.fields - .map((field: any) => this.schemaToType(field)) - .join(",")}]}` - }`, - ) - .join(" | "); - } - case "list": { - if (schema.items instanceof Array) { - return `[${schema.items - .map((item: any) => this.schemaToType(item)) - .join(",")}]`; - } else { - return `Array<${this.schemaToType(schema.items)}>`; + case "map": { + const keysType = this.schemaToType(schema.keys, definitions); + const valuesType = this.schemaToType(schema.values, definitions); + return `Map<${keysType}, ${valuesType}>`; + } + case undefined: { + if (!this.dataImported) { + this.imports += this.pdataImport; + this.dataImported = true; + } + return "PlutusData"; } } - case "map": { - return `Map<${this.schemaToType(schema.keys)}, ${this.schemaToType( - schema.values, - )}>`; + } else if ("anyOf" in schema) { + // When enum has only one entry it's a single constructor/record object + if (schema.anyOf.length === 1) { + return this.schemaToType(schema.anyOf[0], definitions); } - case undefined: { - if (!this.dataImported) { - this.imports += this.pdataImport; - this.dataImported = true; - } - return "PlutusData"; + if (Generator.isUnit(schema)) { + throw new Error("unreachable?"); + } + if (Generator.isBoolean(schema)) { + return "boolean"; } + if (Generator.isNullable(schema)) { + return `${this.schemaToType(schema.anyOf[0].fields[0]!, definitions)} | null`; + } + return schema.anyOf + .map((entry) => { + if (entry.fields.length === 0) { + return `"${entry.title}"`; + } + const key = entry.title; + let valueType: string; + if (entry.fields[0]?.title) { + const fields = entry.fields + .map((f) => `${f.title}: ${this.schemaToType(f, definitions)}`) + .join(", "); + valueType = `{ ${fields} }`; + } else { + const elementType = entry.fields + .map((f) => this.schemaToType(f, definitions)) + .join(", "); + valueType = `[ ${elementType} ]`; + } + + return `{ ${key}: ${valueType} }`; + }) + .join(" | "); + } else if ("$ref" in schema) { + const fullName = schema.$ref.replaceAll("~1", "/"); + const refKey = fullName.split("#/definitions/")[1]; + if (!refKey) { + throw new Error(`Schema has an undefined or empty string $ref`); + } + if (definitions[refKey] === undefined) { + throw new Error( + `Schema refers to ${schema.$ref} (${refKey}), which doesn't appear in the definitions`, + ); + } + if (this.refTypeNames.has(refKey)) { + return this.refTypeNames.get(refKey)!; + } + return this.schemaToType(definitions[refKey], definitions); + } else { + return "any"; } throw new Error("Could not type cast data."); } - static isBoolean(shape: any): boolean { + static isBoolean(shape: Schema): shape is boolean { return ( - shape.anyOf && + "anyOf" in shape && + "title" in shape.anyOf[0] && shape.anyOf[0]?.title === "False" && shape.anyOf[1]?.title === "True" ); } - static isVoid(shape: any): boolean { + static isUnit(shape: Schema): shape is Unit { + return ( + "anyOf" in shape && + "title" in shape.anyOf[0] && + shape.anyOf[0]?.title === "Unit" + ); + } + + static isVoid(shape: Constructor): boolean { return shape.index === 0 && shape.fields.length === 0; } - static isNullable(shape: any): boolean { + static isNullable(shape: Schema): shape is { + anyOf: [ + { title: "Some"; fields: (Schema | Annotated)[] }, + { title: "None" }, + ]; + } { return ( - shape.anyOf && + "anyOf" in shape && + "title" in shape.anyOf[0] && shape.anyOf[0]?.title === "Some" && shape.anyOf[1]?.title === "None" ); } - static snakeToCamel(s: string | undefined): string { - if (!s) return ""; + static snakeToCamel(s: string | undefined): string | undefined { + if (!s) return undefined; const withUnderscore = s.charAt(0) === "_" ? s.charAt(0) : ""; return ( withUnderscore + @@ -229,7 +282,8 @@ const PlutusData = Core.PlutusData;`; ); } - static upperFirst(s: string): string { + static upperFirst(s: string | undefined): string | undefined { + if (!s) return undefined; const withUnderscore = s.charAt(0) === "_" ? s.charAt(0) : ""; return ( withUnderscore + @@ -249,7 +303,6 @@ export async function generateBlueprint({ infile = "plutus.json", outfile = "plutus.ts", useSdk = false, - recursiveType, }: BlueprintArgs) { const plutusJson: Blueprint = JSON.parse(await fs.readFile(infile, "utf8")); @@ -267,6 +320,27 @@ export async function generateBlueprint({ const definitions = plutusJson.definitions; const generator = new Generator(); + for (const key of Object.keys(definitions)) { + // TODO: it would be nice to have a better sense of what definitions we should emit and which not + if (key.startsWith("List")) { + continue; + } + if (key.startsWith("Pair")) { + continue; + } + const typeName = key.replaceAll("/", "_"); + generator.refTypeNames.set(key, typeName); + } + console.log(generator.refTypeNames); + + const typeDefinitionParts = []; + for (const [key, typeName] of generator.refTypeNames.entries()) { + const value = definitions[key]!; + typeDefinitionParts.push( + `export type ${typeName} = ${generator.schemaToType(value, definitions)};`, + ); + } + if (useSdk) { generator.useSDK(); } @@ -279,64 +353,101 @@ export async function generateBlueprint({ const processedTitle = title.replace("/", "_"); const [a, b, c] = processedTitle.split("."); return ( - Generator.upperFirst(Generator.snakeToCamel(a)) + - Generator.upperFirst(Generator.snakeToCamel(b)) + - Generator.upperFirst(Generator.snakeToCamel(c)) + Generator.upperFirst(Generator.snakeToCamel(a))! + + Generator.upperFirst(Generator.snakeToCamel(b))! + + Generator.upperFirst(Generator.snakeToCamel(c))! ); })(); const datum = validator.datum; - const datumTitle = datum ? Generator.snakeToCamel(datum.title) : null; - const datumSchema = datum - ? Generator.resolveSchema(datum.schema, definitions, recursiveType) - : null; + const datumTitle = Generator.snakeToCamel(datum?.title); + const datumSchema = datum?.schema; const redeemer = validator.redeemer; - const redeemerTitle = redeemer.title - ? Generator.snakeToCamel(redeemer.title) - : null; - const redeemerSchema = Generator.resolveSchema( - redeemer.schema, - definitions, - recursiveType, - ); + const redeemerTitle = Generator.snakeToCamel(redeemer?.title); + const redeemerSchema = redeemer?.schema; const params = validator.parameters || []; + + const paramNames = params.map((param) => + Generator.snakeToCamel(param.title), + ); const paramsSchema = { dataType: "list", items: params.map((param) => - Generator.resolveSchema(param.schema, definitions, recursiveType), + Generator.inlineSchema(param.schema, definitions), ), }; - - const paramsArgs = params.map((param, index) => [ - Generator.snakeToCamel(param.title), - generator.schemaToType(paramsSchema.items[index]), - ]); + const paramTypes = params.map((param) => + generator.schemaToType(param.schema, definitions), + ); + const paramsArgs = params.map((_, idx) => { + const name = paramNames[idx]!; + const paramType = paramTypes[idx]!; + return `${name}: ${paramType}`; + }); const script = validator.compiledCode; + const constructorArgsString = `${paramsArgs.join(",\n ")}`; + const parts = []; - return `export interface ${name} { - new (${paramsArgs.map((param) => param.join(":")).join(",")}): Script;${ - datum ? `\n${datumTitle}: ${generator.schemaToType(datumSchema)};` : "" + parts.push( + `export interface ${name} {`, + ` scriptBytes: string;`, + ` new(${constructorArgsString}): Script;`, + ); + if (datum) { + parts.push( + ` ${datumTitle}: ${generator.schemaToType(datumSchema!, definitions)};`, + ); } - ${redeemerTitle ? `${redeemerTitle}: ${generator.schemaToType(redeemerSchema)};` : ""} - }; + if (redeemer) { + parts.push( + ` ${redeemerTitle}: ${generator.schemaToType(redeemerSchema, definitions)};`, + ); + } + parts.push("}"); + + parts.push( + `export const ${name} = Object.assign(`, + ` function (${paramsArgs.join(",")}) {`, + ); + if (paramsArgs.length > 0) { + parts.push( + ` return cborToScript(`, + ` applyParamsToScript(`, + ` ${name}.scriptBytes, `, + ` [${paramNames.join(", ")}],`, + ` ${JSON.stringify(paramsSchema)} as any,`, + ` ),`, + ` ${plutusVersion},`, + ` );`, + ); + } else { + parts.push(` return cborToScript("${script}", ${plutusVersion});`); + } + parts.push(" },"); + parts.push(` { scriptBytes: "${script}" },`); + if (datum) { + parts.push( + ` { ${datumTitle}: ${JSON.stringify(Generator.inlineSchema(datumSchema!, definitions))} },`, + ); + } + if (redeemer) { + parts.push( + ` { ${redeemerTitle}: ${JSON.stringify(Generator.inlineSchema(redeemerSchema!, definitions))} },`, + ); + } + parts.push(`) as unknown as ${name};\n`); - export const ${name} = Object.assign( - function (${paramsArgs.map((param) => param.join(":")).join(",")}) {${ - paramsArgs.length > 0 - ? `return cborToScript(applyParamsToScript("${script}", [${paramsArgs - .map((param) => param[0]) - .join(",")}], ${JSON.stringify( - paramsSchema, - )} as any), ${plutusVersion});` - : `return cborToScript("${script}", ${plutusVersion});` - }}, - ${datum ? `{${datumTitle}: ${JSON.stringify(datumSchema)}},` : ""} - ${redeemerTitle ? `{${redeemerTitle}: ${JSON.stringify(redeemerSchema)}},` : ""} - ) as unknown as ${name};`; + return parts.join("\n"); }); - const plutus = generator.imports + "\n\n" + validators.join("\n\n"); + const plutus = [ + generator.imports, + "\n", + typeDefinitionParts.join("\n"), + "\n", + validators.join("\n\n"), + ].join("\n"); await fs.writeFile(outfile, plutus); } diff --git a/packages/blaze-blueprint/src/data.ts b/packages/blaze-blueprint/src/data.ts new file mode 100644 index 0000000..c04c0b8 --- /dev/null +++ b/packages/blaze-blueprint/src/data.ts @@ -0,0 +1,20 @@ +import type { Annotated, Declaration } from "./shared"; + +export type Integer = { dataType: "integer" }; +export type Bytes = { dataType: "bytes" }; +export type List = { dataType: "list"; items: Data | Data[] }; +export type Map = { + dataType: "map"; + keys: Declaration; + values: Declaration; +}; +export type Constructor = { + dataType: "constructor"; + index: number; + fields: Annotated>[]; +}; +export type Enum = { anyOf: Annotated[] }; +// eslint-disable-next-line +export type Opaque = {}; + +export type Data = Integer | Bytes | List | Map | Enum | Opaque; diff --git a/packages/blaze-blueprint/src/schema.ts b/packages/blaze-blueprint/src/schema.ts new file mode 100644 index 0000000..9494962 --- /dev/null +++ b/packages/blaze-blueprint/src/schema.ts @@ -0,0 +1,50 @@ +import type { Data } from "./data"; +import type { Declaration } from "./shared"; + +export type Unit = { + title: "Unit"; + anyOf: [{ dataType: "constructor"; index: 0; fields: [] }]; +}; +export const Unit: Unit = { + title: "Unit", + anyOf: [{ dataType: "constructor", index: 0, fields: [] }], +}; + +export type Boolean = { + title: "Bool"; + anyOf: [ + { + title: "False"; + dataType: "constructor"; + index: 0; + fields: []; + }, + { + title: "True"; + dataType: "constructor"; + index: 1; + fields: []; + }, + ]; +}; + +export type Int = { dataType: "integer" }; + +export type Bytes = { dataType: "bytes" }; + +export type String = { dataType: "#string" }; + +export type Pair = { + title: "Pair"; + dataType: "#pair"; + left: Declaration; + right: Declaration; +}; + +export type List = { + dataType: "list"; + items: Schema | Schema[]; +}; + +// eslint-disable-next-line +export type Schema = Unit | Boolean | Int | Bytes | String | Pair | List | Data; diff --git a/packages/blaze-blueprint/src/shared.ts b/packages/blaze-blueprint/src/shared.ts new file mode 100644 index 0000000..7ecb3f3 --- /dev/null +++ b/packages/blaze-blueprint/src/shared.ts @@ -0,0 +1,2 @@ +export type Annotated = T & { title?: string; description?: string }; +export type Declaration = { $ref: string } | T; diff --git a/packages/blaze-tsconfig/base.json b/packages/blaze-tsconfig/base.json index dbdecac..4723aa2 100644 --- a/packages/blaze-tsconfig/base.json +++ b/packages/blaze-tsconfig/base.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "display": "Default", "compilerOptions": { - "lib": ["ES2020"], + "lib": ["ES2021"], "target": "ES2020", "module": "es2020", "moduleResolution": "Bundler", diff --git a/packages/blaze-tx/src/data.ts b/packages/blaze-tx/src/data.ts index 1554620..f61b4ba 100644 --- a/packages/blaze-tx/src/data.ts +++ b/packages/blaze-tx/src/data.ts @@ -344,6 +344,7 @@ export const Data = { }; export type Exact = T extends TSchema ? Static : T; + function to(data: Exact, type: T, recType?: string): PlutusData; function to(data: Data, type?: never): PlutusData; function to(