Skip to content

Commit

Permalink
feat: codegen typed interfaces for functions in noir_codegen (#3533)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomAFrench authored Nov 27, 2023
1 parent 990ac0a commit 290c463
Show file tree
Hide file tree
Showing 10 changed files with 343 additions and 16 deletions.
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/[email protected]

- 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/[email protected]
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: [email protected]

- 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 };

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

0 comments on commit 290c463

Please sign in to comment.