Skip to content
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

feat: codegen typed interfaces for functions in noir_codegen #3533

Merged
merged 9 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
97 changes: 97 additions & 0 deletions .github/workflows/test-noir_codegen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: noir_codegen

on:
pull_request:
merge_group:
push:
branches:
- master

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref || github.run_id }}
cancel-in-progress: true

jobs:
build-nargo:
runs-on: ubuntu-22.04
strategy:
matrix:
target: [x86_64-unknown-linux-gnu]

steps:
- name: Checkout Noir repo
uses: actions/checkout@v4

- name: Setup toolchain
uses: dtolnay/rust-toolchain@1.71.1

- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
cache-on-failure: true
save-if: ${{ github.event_name != 'merge_group' }}

- name: Build Nargo
run: cargo build --package nargo_cli --release

- name: Package artifacts
run: |
mkdir dist
cp ./target/release/nargo ./dist/nargo
7z a -ttar -so -an ./dist/* | 7z a -si ./nargo-x86_64-unknown-linux-gnu.tar.gz

- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: nargo
path: ./dist/*
retention-days: 3

test:
needs: [build-nargo]
name: Test noir_codegen
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Yarn dependencies
uses: ./.github/actions/setup

- name: Setup toolchain
uses: dtolnay/rust-toolchain@1.71.1
with:
targets: wasm32-unknown-unknown

- uses: Swatinem/rust-cache@v2
with:
key: wasm32-unknown-unknown-noir-js
cache-on-failure: true
save-if: ${{ github.event_name != 'merge_group' }}

- name: Install jq
run: sudo apt-get install jq

- name: Install wasm-bindgen-cli
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.86

- name: Install wasm-opt
run: |
npm i wasm-opt -g

- name: Build acvm_js
run: yarn workspace @noir-lang/acvm_js build

- name: Build noirc_abi
run: yarn workspace @noir-lang/noirc_abi build

- name: Build noir_js_types
run: yarn workspace @noir-lang/types build

- name: Build noir_js
run: yarn workspace @noir-lang/noir_js build

- name: Run noir_codegen tests
run: yarn workspace @noir-lang/noir_codegen test
1 change: 1 addition & 0 deletions tooling/noir_codegen/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ crs
lib

!test/*/target
test/codegen
4 changes: 3 additions & 1 deletion tooling/noir_codegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
"scripts": {
"dev": "tsc-multi --watch",
"build": "tsc",
"test": "ts-node --esm src/main.ts ./test/assert_lt/target/** --out-dir ./test/codegen && yarn test:node && rm -rf ./test/codegen",
"test": "yarn test:codegen && yarn test:node && yarn test:clean",
"test:codegen": "ts-node --esm src/main.ts ./test/assert_lt/target/** --out-dir ./test/codegen",
"test:node": "mocha --timeout 25000 --exit --config ./.mocharc.json",
"test:clean": "rm -rf ./test/codegen",
"prettier": "prettier 'src/**/*.ts'",
"prettier:fix": "prettier --write 'src/**/*.ts' 'test/**/*.ts'",
"lint": "NODE_NO_WARNINGS=1 eslint . --ext .ts --ignore-path ./.eslintignore --max-warnings 0",
Expand Down
43 changes: 36 additions & 7 deletions tooling/noir_codegen/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,53 @@
import { CompiledCircuit } from '@noir-lang/types';
import { PrimitiveTypesUsed, generateTsInterface } from './noir_types.js';

const codegenImports = `import { InputMap, InputValue } from "@noir-lang/noirc_abi"
import { Noir } from "@noir-lang/noir_js"`;
// TODO: reenable this. See `abiTypeToTs` for reasoning.
// export type FixedLengthArray<T, L extends number> = L extends 0 ? never[]: T[] & { length: L };
TomAFrench marked this conversation as resolved.
Show resolved Hide resolved

const codegenPrelude = `/* Autogenerated file, do not edit! */

/* eslint-disable */

import { Noir, InputMap } from "@noir-lang/noir_js"
`;

const codegenFunction = (
name: string,
compiled_program: CompiledCircuit,
) => `export async function ${name}(args: InputMap): Promise<InputValue> {
function_signature: { inputs: [string, string][]; returnValue: string | null },
) => {
const args = function_signature.inputs.map(([name]) => `${name}`).join(', ');
const args_with_types = function_signature.inputs.map(([name, type]) => `${name}: ${type}`).join(', ');

return `export async function ${name}(${args_with_types}): Promise<${function_signature.returnValue}> {
const program = new Noir(${JSON.stringify(compiled_program)});
const args: InputMap = { ${args} };
const { returnValue } = await program.execute(args);
return returnValue;
return returnValue as ${function_signature.returnValue};
}`;
};

export const codegen = (programs: [string, CompiledCircuit][]): string => {
const results = [codegenImports];
let results = [codegenPrelude];
const primitiveTypeMap = new Map<string, PrimitiveTypesUsed>();

const functions: string[] = [];
for (const [name, program] of programs) {
results.push(codegenFunction(name, stripUnwantedFields(program)));
const [types_string, function_sig] = generateTsInterface(program.abi, primitiveTypeMap);
functions.push(types_string);
functions.push('\n');
functions.push(codegenFunction(name, stripUnwantedFields(program), function_sig));
}

// Add the primitive Noir types that do not have a 1-1 mapping to TypeScript.
const primitiveTypeAliases: string[] = [];
for (const value of primitiveTypeMap.values()) {
primitiveTypeAliases.push(`export type ${value.aliasName} = ${value.tsType};`);
}

return results.join('\n\n');
results = results.concat(...primitiveTypeAliases, ...functions);

return results.filter((val) => val !== '').join('\n');
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
185 changes: 185 additions & 0 deletions tooling/noir_codegen/src/noir_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { AbiType, Abi } from '@noir-lang/noirc_abi';

/**
* Keep track off all of the Noir primitive types that were used.
* Most of these will not have a 1-1 definition in TypeScript,
* so we will need to generate type aliases for them.
*
* We want to generate type aliases
* for specific types that are used in the ABI.
*
* For example:
* - If `Field` is used we want to alias that
* with `number`.
* - If `u32` is used we want to alias that with `number` too.
*/
export type PrimitiveTypesUsed = {
/**
* The name of the type alias that we will generate.
*/
aliasName: string;
/**
* The TypeScript type that we will alias to.
*/
tsType: string;
};

/**
* Typescript does not allow us to check for equality of non-primitive types
* easily, so we create a addIfUnique function that will only add an item
* to the map if it is not already there by using JSON.stringify.
* @param item - The item to add to the map.
*/
function addIfUnique(primitiveTypeMap: Map<string, PrimitiveTypesUsed>, item: PrimitiveTypesUsed) {
const key = JSON.stringify(item);
if (!primitiveTypeMap.has(key)) {
primitiveTypeMap.set(key, item);
}
}

/**
* Converts an ABI type to a TypeScript type.
* @param type - The ABI type to convert.
* @returns The typescript code to define the type.
*/
function abiTypeToTs(type: AbiType, primitiveTypeMap: Map<string, PrimitiveTypesUsed>): string {
switch (type.kind) {
case 'field':
addIfUnique(primitiveTypeMap, { aliasName: 'Field', tsType: 'string' });
return 'Field';
case 'integer': {
const typeName = type.sign === 'signed' ? `i${type.width}` : `u${type.width}`;
// Javascript cannot safely represent the full range of Noir's integer types as numbers.
// `Number.MAX_SAFE_INTEGER == 2**53 - 1` so we disallow passing numbers to types which may exceed this.
// 52 has been chosen as the cutoff rather than 53 for safety.
const tsType = type.width <= 52 ? `string | number` : `string`;

addIfUnique(primitiveTypeMap, { aliasName: typeName, tsType });
return typeName;
}
case 'boolean':
return `boolean`;
case 'array':
// We can't force the usage of fixed length arrays as this currently throws errors in TS.
// The array would need to be `as const` to support this whereas that's unlikely to happen in user code.
// return `FixedLengthArray<${abiTypeToTs(type.type, primitiveTypeMap)}, ${type.length}>`;
return `${abiTypeToTs(type.type, primitiveTypeMap)}[]`;
case 'string':
// We could enforce that literals are the correct length but not generally.
// This would run into similar problems to above.
return `string`;
case 'struct':
return getLastComponentOfPath(type.path);
default:
throw new Error(`Unknown ABI type ${JSON.stringify(type)}`);
}
}

/**
* Returns the last component of a path, e.g. "foo::bar::baz" -\> "baz"
* Note: that if we have a path such as "Baz", we will return "Baz".
*
* Since these paths corresponds to structs, we can assume that we
* cannot have "foo::bar::".
*
* We also make the assumption that since these paths are coming from
* Noir, then we will not have two paths that look like this:
* - foo::bar::Baz
* - cat::dog::Baz
* ie the last component of the path (struct name) is enough to uniquely identify
* the whole path.
*
* TODO: We should double check this assumption when we use type aliases,
* I expect that `foo::bar::Baz as Dog` would effectively give `foo::bar::Dog`
* @param str - The path to get the last component of.
* @returns The last component of the path.
*/
function getLastComponentOfPath(str: string): string {
const parts = str.split('::');
const lastPart = parts[parts.length - 1];
return lastPart;
}

/**
* Generates TypeScript interfaces for the structs used in the ABI.
* @param type - The ABI type to generate the interface for.
* @param output - The set of structs that we have already generated bindings for.
* @returns The TypeScript code to define the struct.
*/
function generateStructInterfaces(
type: AbiType,
output: Set<string>,
primitiveTypeMap: Map<string, PrimitiveTypesUsed>,
): string {
let result = '';

// Edge case to handle the array of structs case.
if (type.kind === 'array' && type.type.kind === 'struct' && !output.has(getLastComponentOfPath(type.type.path))) {
result += generateStructInterfaces(type.type, output, primitiveTypeMap);
}
if (type.kind !== 'struct') return result;

// List of structs encountered while viewing this type that we need to generate
// bindings for.
const typesEncountered = new Set<AbiType>();

// Codegen the struct and then its fields, so that the structs fields
// are defined before the struct itself.
let codeGeneratedStruct = '';
let codeGeneratedStructFields = '';

const structName = getLastComponentOfPath(type.path);
if (!output.has(structName)) {
codeGeneratedStruct += `export type ${structName} = {\n`;
for (const field of type.fields) {
codeGeneratedStruct += ` ${field.name}: ${abiTypeToTs(field.type, primitiveTypeMap)};\n`;
typesEncountered.add(field.type);
}
codeGeneratedStruct += `};`;
output.add(structName);

// Generate code for the encountered structs in the field above
for (const type of typesEncountered) {
codeGeneratedStructFields += generateStructInterfaces(type, output, primitiveTypeMap);
}
}

return codeGeneratedStructFields + '\n' + codeGeneratedStruct;
}

/**
* Generates a TypeScript interface for the ABI.
* @param abiObj - The ABI to generate the interface for.
* @returns The TypeScript code to define the interface.
*/
export function generateTsInterface(
abiObj: Abi,
primitiveTypeMap: Map<string, PrimitiveTypesUsed>,
): [string, { inputs: [string, string][]; returnValue: string | null }] {
let result = ``;
const outputStructs = new Set<string>();

// Define structs for composite types
for (const param of abiObj.parameters) {
result += generateStructInterfaces(param.type, outputStructs, primitiveTypeMap);
}

// Generating Return type, if it exists
if (abiObj.return_type != null) {
result += generateStructInterfaces(abiObj.return_type, outputStructs, primitiveTypeMap);
}

return [result, getTsFunctionSignature(abiObj, primitiveTypeMap)];
}

function getTsFunctionSignature(
abi: Abi,
primitiveTypeMap: Map<string, PrimitiveTypesUsed>,
): { inputs: [string, string][]; returnValue: string | null } {
const inputs: [string, string][] = abi.parameters.map((param) => [
param.name,
abiTypeToTs(param.type, primitiveTypeMap),
]);
const returnValue = abi.return_type ? abiTypeToTs(abi.return_type, primitiveTypeMap) : null;
return { inputs, returnValue };
}
11 changes: 10 additions & 1 deletion tooling/noir_codegen/test/assert_lt/src/main.nr
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
fn main(x: u64, y: pub u64) -> pub u64 {
struct MyStruct {
foo: bool,
bar: [str<5>; 3],
}

fn main(x: u64, y: pub u64, array: [u8; 5], my_struct: MyStruct, string: str<5>) -> pub u64 {
assert(array.len() == 5);
assert(my_struct.foo);
assert(string == "12345");

assert(x < y);
x + y
}
2 changes: 1 addition & 1 deletion tooling/noir_codegen/test/assert_lt/target/assert_lt.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"hash":13834844072603749544,"backend":"acvm-backend-barretenberg","abi":{"parameters":[{"name":"x","type":{"kind":"integer","sign":"unsigned","width":64},"visibility":"private"},{"name":"y","type":{"kind":"integer","sign":"unsigned","width":64},"visibility":"public"}],"param_witnesses":{"x":[1],"y":[2]},"return_type":{"kind":"integer","sign":"unsigned","width":64},"return_witnesses":[12]},"bytecode":"H4sIAAAAAAAA/+1WUW6DMAx1QksZoGr72jUcAiX8VbvJ0Oj9j7ChJpKbtXw0NpvUWkImUXixn53w3gDgHc6mfh7t/ZGMtR9TU96HeYuHtp36ZjLWfGIzjK7DthsPzjjTue6rcdZOrnX9MA49Dqa1kzl1gz3h2bL7sTDCMhmJbylmTDOT8WEhjXfjH/DcB8u8zwVygWifmL/9lTnWzSWKsxHA3QJf00vlveWvERJIUU4x0eb86aEJppljVox9oO+Py8QTV1Jnw6a85t7vSL8pwvN89j7gd88o8q79Gr2wRt3AeSFz4XvRSyokl5MAtSfgGO2ZCewdsDibLRVrDzIXTMxfqiLIGXPeMdY1gb/Fg8+tznJY50eSGmfB2DNrqciCD+tCRc4X5FNFJmIWnkhu3BL+t4qc8y75aySqIkvGOP9CRWKaGQ0ydUrsgUUVWXlfw4OpyAouVWQN66pITDPDqSJfQaZxuVVkxZhzzVgLTv5uHbDwXhN+vwGywklHPBQAAA=="}
{"noir_version":"0.19.2+87bb3f0d789765f2d65a1e7b7554742994da2680","hash":12941906747567599524,"backend":"acvm-backend-barretenberg","abi":{"parameters":[{"name":"x","type":{"kind":"integer","sign":"unsigned","width":64},"visibility":"private"},{"name":"y","type":{"kind":"integer","sign":"unsigned","width":64},"visibility":"public"},{"name":"array","type":{"kind":"array","length":5,"type":{"kind":"integer","sign":"unsigned","width":8}},"visibility":"private"},{"name":"my_struct","type":{"kind":"struct","path":"MyStruct","fields":[{"name":"foo","type":{"kind":"boolean"}},{"name":"bar","type":{"kind":"array","length":3,"type":{"kind":"string","length":5}}}]},"visibility":"private"},{"name":"string","type":{"kind":"string","length":5},"visibility":"private"}],"param_witnesses":{"array":[{"start":3,"end":8}],"my_struct":[{"start":8,"end":24}],"string":[{"start":24,"end":29}],"x":[{"start":1,"end":2}],"y":[{"start":2,"end":3}]},"return_type":{"kind":"integer","sign":"unsigned","width":64},"return_witnesses":[31]},"bytecode":"H4sIAAAAAAAA/82X206DQBCGF+qh9VDP2gO0eKlXuwVauGt8k7Ys0URTY4h9fTvprm4HJVFmEych8FE6/Ay7zP63jLF7tglnvblqPzXYRdxYb02DdxDvIt5DvK9Y35Op/BC8XoimcS8zb8jHUSQnIylCMeOjdJ7EPIrn40QkIk7ibJSEoUyiZJLO0wlPRRRKkcdpmKvETTqNXNehhepygPgQ8RHiY8RtxCeITxGfIT5HfIH4EvEV4mvEN4g7iLuIe4j7iD32NW502Bg/U6IxY1Nnh0CnzCEyqzq7ZDoXuU2dPTqd0qbOPp3OzKZOj07nAvqNy8rhEmt2GN3cd/+uS+AT3zw6WW6zrr7aD9imh+txoa+BPv/AymPGMY5ddY1bcY3zQ56WcU7/v238XvfhS8Uwb06V01eFpF6A+HQaPxcgAyOnjgZxPWxNqrq5AsJ6VtXvlzo50il8wmceEL7XGvWr/MD953lT9Z55vdiaJ7xeCMp5MmT03x2ds2+8c6gnNBhoPGAYtUmEpgDGCMwQGCAwPdAUwNyAoQETA8YFzAoYFDAlYETAfMAiGRagPXUvj203Kn08ZNtN5k7tPbWfFYV8eS2CYhnMsixYPRWPwfJdvuXPy9UHoDK8FUEPAAA="}
Loading
Loading