diff --git a/yarn-project/boxes/blank-react/.eslintrc.cjs b/yarn-project/boxes/blank-react/.eslintrc.cjs new file mode 100644 index 00000000000..93359038995 --- /dev/null +++ b/yarn-project/boxes/blank-react/.eslintrc.cjs @@ -0,0 +1,61 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'prettier', + ], + settings: { + 'import/resolver': { + typescript: true, + node: true, + }, + }, + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + overrides: [ + { + files: ['*.ts', '*.tsx'], + parserOptions: { + // hacky workaround for CI not having the same tsconfig setup + project: true, + }, + }, + ], + rules: { + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-floating-promises': 2, + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'require-await': 2, + 'no-console': 'warn', + 'no-constant-condition': 'off', + camelcase: 2, + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['client-dest'], + message: "Fix this absolute garbage import. It's your duty to solve it before it spreads.", + }, + { + group: ['dest'], + message: 'You should not be importing from a build directory. Did you accidentally do a relative import?', + }, + ], + }, + ], + 'import/no-unresolved': 'error', + 'import/no-extraneous-dependencies': 'error', + }, +}; diff --git a/yarn-project/boxes/blank-react/.gitignore b/yarn-project/boxes/blank-react/.gitignore new file mode 100644 index 00000000000..e7e0c8d327f --- /dev/null +++ b/yarn-project/boxes/blank-react/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dest +dest-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/yarn-project/boxes/blank-react/.prettierrc.json b/yarn-project/boxes/blank-react/.prettierrc.json new file mode 100644 index 00000000000..7c3bbec6848 --- /dev/null +++ b/yarn-project/boxes/blank-react/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "arrowParens": "avoid" +} diff --git a/yarn-project/boxes/blank-react/README.md b/yarn-project/boxes/blank-react/README.md new file mode 100644 index 00000000000..13f91c21621 --- /dev/null +++ b/yarn-project/boxes/blank-react/README.md @@ -0,0 +1,79 @@ +This is a minimal [Aztec](https://aztec.network/) Noir smart contract and frontend bootstrapped with [`aztec-cli unbox`](https://github.com/AztecProtocol/aztec-packages/tree/master/yarn-project/cli). It is recommended you use the `aztec-cli unbox blank-react` command so that the repository is copied with needed modifications from the monorepo subpackage. + +## Setup + +Dependencies can be installed from the root of the package: + +```bash +yarn +yarn install:noir +yarn install:sandbox +``` + +This sandbox requires [Docker](https://www.docker.com/) to be installed _and running_ locally. In the event the image needs updating, you can run `yarn install:sandbox` (see [sandbox docs](https://aztec-docs-dev.netlify.app/dev_docs/getting_started/sandbox) for more information.) + +In addition to the usual javascript dependencies, this project requires `nargo` (package manager) and `noir` (Aztec ZK smart contract language) in addition to `@aztec/aztec-cli`. The former are installed within `yarn install:noir` + +## Getting started + +After `yarn` has run,`yarn start:sandbox` in one terminal will launch a local instance of the Aztec sandbox via Docker Compose and `yarn start:dev` will launch a frontend app for deploying and interacting with an empty Aztec smart contract. + +At this point, [http://localhost:5173](http://localhost:5173) should provide a minimal smart contract frontend. + +This folder should have the following directory structure: + +``` +|— README.md +|— package.json +|— src + |-config.ts - Blank Contract specific configuration for the frontend. + | You may need to update this if you modify the contract functions. + |— app + |— [frontend React .tsx code files] + |- scripts + |- [helpers for frontend to interact with contract on the sandbox] + |— contracts + |— src + | The Noir smart contract source files are here. + |— main.nr - the cloned noir contract, your starting point + |- interface.nr - autogenerated from main.nr when you compile + |— Nargo.toml [Noir build file, includes Aztec smart contract dependencies] + |— artifacts + | These are both generated from `contracts/` by the compile command + |— blank_contract.json + |— blank.ts + |— tests + | A simple end2end test deploying and testing the Blank contract deploys on a local sandbox + | The test requires the sandbox and anvil to be running (yarn start:sandbox). + |- blank.contract.test.ts +``` + +Most relevant to you is likely `src/contracts/main.nr` (and the build config `src/contracts/Nargo.toml`). This contains the example blank contract logic that the frontend interacts with and is a good place to start writing Noir. + +The `src/artifacts` folder can be re-generated from the command line + +```bash +yarn compile +``` + +This will generate a [Contract ABI](src/artifacts/test_contract.json) and TypeScript class for the [Aztec smart contract](src/contracts/main.nr), which the frontend uses to generate the UI. + +Note: the `compile` command seems to generate a Typescript file which needs a single change - + +``` +import TestContractAbiJson from 'text_contract.json' assert { type: 'json' }; +// need to update the relative import to +import TestContractAbiJson from './test_contract.json' assert { type: 'json' }; +``` + +After compiling, you can re-deploy the upated noir smart contract from the web UI. The function interaction forms are generated from parsing the ContractABI, so they should update automatically after you recompile. + +## Learn More + +To learn more about Noir Smart Contract development, take a look at the following resources: + +- [Awesome Noir](https://github.com/noir-lang/awesome-noir) - learn about the Noir programming language. + +## Deploy on Aztec3 + +Coming Soon :) diff --git a/yarn-project/boxes/blank-react/package.json b/yarn-project/boxes/blank-react/package.json new file mode 100644 index 00000000000..5ac133c3187 --- /dev/null +++ b/yarn-project/boxes/blank-react/package.json @@ -0,0 +1,101 @@ +{ + "name": "blank-contract-react", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "./dest/index.js", + "scripts": { + "build": "yarn clean && webpack", + "install:noir": "curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash noirup -v aztec", + "install:sandbox": "docker pull aztecprotocol/aztec-sandbox:latest", + "clean": "rm -rf ./dest .tsbuildinfo", + "start": "serve -p 3000 ./dest", + "start:dev": "webpack serve --mode=development", + "start:sandbox": "SANDBOX_VERSION=latest /bin/bash -c \"$(curl -fsSL 'https://sandbox.aztec.network')\" ", + "formatting": "prettier --check ./src && eslint ./src", + "formatting:fix": "prettier -w ./src", + "compile": "aztec-cli compile src/contracts --outdir ../artifacts --typescript ../artifacts", + "test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules $(yarn bin jest) --runInBand", + "test:integration": "concurrently -k -s first -c reset,dim -n test,anvil \"yarn test\" \"anvil\"" + }, + "jest": { + "preset": "ts-jest/presets/default-esm", + "globals": { + "ts-jest": { + "useESM": true + } + }, + "transform": { + "^.+\\.(ts|tsx)$": "ts-jest" + }, + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, + "testRegex": "./src/.*\\.test\\.ts$", + "rootDir": "./src" + }, + "dependencies": { + "@aztec/aztec-ui": "^0.1.14", + "@aztec/aztec.js": "workspace:^", + "@aztec/circuits.js": "workspace:^", + "@aztec/cli": "workspace:^", + "@aztec/foundation": "workspace:^", + "classnames": "^2.3.2", + "formik": "^2.4.3", + "node-sass": "^9.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass-loader": "^13.3.2", + "serve": "^14.2.1", + "yup": "^1.2.0" + }, + "devDependencies": { + "@types/node": "^20.5.9", + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "autoprefixer": "^10.4.15", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^6.8.1", + "eslint": "^8.45.0", + "eslint-import-resolver-typescript": "^3.5.5", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "jest": "^29.6.4", + "postcss": "^8.4.29", + "postcss-loader": "^7.3.3", + "prettier": "^3.0.3", + "resolve-typescript-plugin": "^2.0.1", + "stream-browserify": "^3.0.0", + "style-loader": "^3.3.3", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.4", + "ts-node": "^10.9.1", + "tty-browserify": "^0.0.1", + "typescript": "^5.0.4", + "util": "^0.12.5", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1" + }, + "browserslist": { + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "files": [ + "dest", + "src", + "!*.test.*" + ], + "types": "./dest/index.d.ts" +} diff --git a/yarn-project/boxes/blank-react/src/@types/index.d.ts b/yarn-project/boxes/blank-react/src/@types/index.d.ts new file mode 100644 index 00000000000..3598bbcb0be --- /dev/null +++ b/yarn-project/boxes/blank-react/src/@types/index.d.ts @@ -0,0 +1,9 @@ +declare module '*.svg' { + const content: any; + export default content; +} + +declare module '*.module.scss' { + const content: { [className: string]: string }; + export = content; +} diff --git a/yarn-project/boxes/blank-react/src/app/components/contract_function_form.module.scss b/yarn-project/boxes/blank-react/src/app/components/contract_function_form.module.scss new file mode 100644 index 00000000000..f28047b581a --- /dev/null +++ b/yarn-project/boxes/blank-react/src/app/components/contract_function_form.module.scss @@ -0,0 +1,67 @@ +.input { + border: none; + outline-width: 0; + outline-color: rgba(0, 0, 0, 0); + padding: 2px 20px 0 20px; + width: 100%; + height: 45px; + color: #000; + border: 1px solid rgba(0, 0, 0, 0); + font-size: 16px; + text-align: left; + font-weight: 400; + border-radius: 10px; + text-align: left; + text-overflow: ellipsis; + transition: box-shadow .2s; + box-shadow: 0px 4px 10px rgba(0, 0, 0, .1); + background-color: white; + -webkit-appearance: none; + + + &:disabled { + color: #4a4a4a; + background-color: rgba(239, 239, 239, 0.3); + background: radial-gradient(rgba(239, 239, 239, 0.3), rgba(239, 239, 239, 0.3)); + -webkit-text-fill-color: #4a4a4a; + cursor: not-allowed; + } +} + +.label { + font-weight: 450; + font-size: 18px; + display: flex; + width: 100%; + flex-direction: column; + text-align: left; + margin-bottom: 15px; + justify-content: space-between; +} + +.inputWrapper { + width: 100%; + display: flex; + gap: 15px; +} + +.field { + display: flex; + justify-content: start; + flex-direction: column; + align-items: flex-start; +} + +.content { + display: flex; + justify-content: space-between; + flex-direction: column; + margin: 30px; + width: 450px; + gap: 30px; +} + +.actionButton { + width: 100%; + align-self: center; +} \ No newline at end of file diff --git a/yarn-project/boxes/blank-react/src/app/components/contract_function_form.tsx b/yarn-project/boxes/blank-react/src/app/components/contract_function_form.tsx new file mode 100644 index 00000000000..e31c940a927 --- /dev/null +++ b/yarn-project/boxes/blank-react/src/app/components/contract_function_form.tsx @@ -0,0 +1,165 @@ +import { Button, Loader } from '@aztec/aztec-ui'; +import { AztecAddress, CompleteAddress, Fr } from '@aztec/aztec.js'; +import { ContractAbi, FunctionAbi } from '@aztec/foundation/abi'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import { CONTRACT_ADDRESS_PARAM_NAMES, rpcClient } from '../../config.js'; +import { callContractFunction, deployContract, viewContractFunction } from '../../scripts/index.js'; +import { convertArgs } from '../../scripts/util.js'; +import styles from './contract_function_form.module.scss'; + +type NoirFunctionYupSchema = { + // hack: add `any` at the end to get the array schema to typecheck + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: Yup.NumberSchema | Yup.ArraySchema | Yup.BooleanSchema | any; +}; + +type NoirFunctionFormValues = { + [key: string]: string | number | number[] | boolean; +}; + +function generateYupSchema(functionAbi: FunctionAbi, defaultAddress: string) { + const parameterSchema: NoirFunctionYupSchema = {}; + const initialValues: NoirFunctionFormValues = {}; + for (const param of functionAbi.parameters) { + if (CONTRACT_ADDRESS_PARAM_NAMES.includes(param.name)) { + // these are hex strings instead, but yup doesn't support bigint so we convert back to bigint on execution + parameterSchema[param.name] = Yup.string().required(); + initialValues[param.name] = defaultAddress; + continue; + } + switch (param.type.kind) { + case 'field': + parameterSchema[param.name] = Yup.number().required(); + initialValues[param.name] = 100; + break; + // not really needed for private token, since we hide the nullifier helper method which has the array input + case 'array': + // eslint-disable-next-line no-case-declarations + const arrayLength = param.type.length; + parameterSchema[param.name] = Yup.array() + .of(Yup.number()) + .min(arrayLength) + .max(arrayLength) + .transform(function (value: number[], originalValue: string) { + if (typeof originalValue === 'string') { + return originalValue.split(',').map(Number); + } + return value; + }); + initialValues[param.name] = Array(arrayLength).fill( + CONTRACT_ADDRESS_PARAM_NAMES.includes(param.name) ? defaultAddress : 200, + ); + break; + case 'boolean': + parameterSchema[param.name] = Yup.boolean().required(); + initialValues[param.name] = false; + break; + } + } + return { validationSchema: Yup.object().shape(parameterSchema), initialValues }; +} + +async function handleFunctionCall( + contractAddress: AztecAddress | undefined, + contractAbi: ContractAbi, + functionName: string, + args: any, + wallet: CompleteAddress, +) { + const functionAbi = contractAbi.functions.find(f => f.name === functionName)!; + const typedArgs: any[] = convertArgs(functionAbi, args); + + if (functionName === 'constructor' && !!wallet) { + if (functionAbi === undefined) { + throw new Error('Cannot find constructor in the ABI.'); + } + // hack: addresses are stored as string in the form to avoid bigint compatibility issues with formik + // convert those back to bigints before sending + + // for now, dont let user change the salt. requires some change to the form generation if we want to let user choose one + // since everything is currently based on parsing the contractABI, and the salt parameter is not present there + const salt = Fr.random(); + return await deployContract(wallet, contractAbi, typedArgs, salt, rpcClient); + } + + if (functionAbi.functionType === 'unconstrained') { + return await viewContractFunction(contractAddress!, contractAbi, functionName, typedArgs, rpcClient, wallet); + } else { + const txnReceipt = await callContractFunction(contractAddress!, contractAbi, functionName, typedArgs, rpcClient, wallet); + return `Transaction ${txnReceipt.status} on block number ${txnReceipt.blockNumber}`; + } +} + +interface ContractFunctionFormProps { + wallet: CompleteAddress; + contractAddress?: AztecAddress; + contractAbi: ContractAbi; + functionAbi: FunctionAbi; + defaultAddress: string; + title?: string; + buttonText?: string; + isLoading: boolean; + disabled: boolean; + onSubmit: () => void; + onSuccess: (result: any) => void; + onError: (msg: string) => void; +} + +export function ContractFunctionForm({ + wallet, + contractAddress, + contractAbi, + functionAbi, + defaultAddress, + buttonText = 'Submit', + isLoading, + disabled, + onSubmit, + onSuccess, + onError, +}: ContractFunctionFormProps) { + const { validationSchema, initialValues } = generateYupSchema(functionAbi, defaultAddress); + const formik = useFormik({ + initialValues: initialValues, + validationSchema: validationSchema, + onSubmit: async (values: any) => { + onSubmit(); + try { + const result = await handleFunctionCall(contractAddress, contractAbi, functionAbi.name, values, wallet); + onSuccess(result); + } catch (e: any) { + onError(e.message); + } + }, + }); + + return ( +
+ {functionAbi.parameters.map(input => ( +
+ + + {formik.touched[input.name] && formik.errors[input.name] && ( +
{formik.errors[input.name]?.toString()}
+ )} +
+ ))} + {isLoading ? ( + + ) : ( +