diff --git a/.circleci/config.yml b/.circleci/config.yml index d745187b5b3f..1df6587602aa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -949,6 +949,18 @@ jobs: name: "Test" command: cond_spot_run_tests end-to-end aztec_rpc_sandbox.test.ts docker-compose-e2e-sandbox.yml + guides-writing-an-account-contract: + machine: + image: ubuntu-2004:202010-01 + resource_class: large + steps: + - *checkout + - *setup_env + - run: + name: "Test" + command: ./scripts/cond_run_script end-to-end $JOB_NAME ./scripts/run_tests_local guides/writing_an_account_contract.test.ts + working_directory: yarn-project/end-to-end + e2e-canary-test: docker: - image: aztecprotocol/alpine-build-image @@ -1355,6 +1367,7 @@ workflows: - e2e-canary-test: *e2e_test - e2e-browser-sandbox: *e2e_test - aztec-rpc-sandbox: *e2e_test + - guides-writing-an-account-contract: *e2e_test - e2e-end: requires: @@ -1384,6 +1397,7 @@ workflows: - e2e-browser-sandbox - e2e-canary-test - aztec-rpc-sandbox + - guides-writing-an-account-contract <<: *defaults - deploy-dockerhub: diff --git a/docs/docs/dev_docs/wallets/writing_an_account_contract.md b/docs/docs/dev_docs/wallets/writing_an_account_contract.md index 1cee38c54ec8..c2f3fcd1560f 100644 --- a/docs/docs/dev_docs/wallets/writing_an_account_contract.md +++ b/docs/docs/dev_docs/wallets/writing_an_account_contract.md @@ -1,3 +1,102 @@ # Writing an Account Contract -Please use the [TUTORIAL-TEMPLATE](../../TUTORIAL_TEMPLATE.md) for standalone guides / tutorials. \ No newline at end of file +This tutorial will take you through the process of writing your own account contract in Noir, along with the Typescript glue code required for using it within a [wallet](./main.md). + +Writing your own account contract allows you to define the rules by which user transactions are authorised and paid for, as well as how user keys are managed (including key rotation and recovery). In other words, writing an account contract lets you make the most out of [account abstraction](../../concepts/foundation/accounts/main.md#what-is-account-abstraction) in the Aztec network. + +It is highly recommended that you understand how an [account](../../concepts/foundation/accounts/main.md) is defined in Aztec, as well as the differences between privacy and authentication [keys](../../concepts/foundation/accounts/keys.md). You will also need to know how to write a [contract in Noir](../contracts/main.md), as well as some basic [Typescript](https://www.typescriptlang.org/). + +For this tutorial, we will write an account contract that uses Schnorr signatures for authenticating transaction requests. + +> That is, every time a transaction payload is passed to this account contract's 'entrypoint' function, the account contract will demand a valid Schnorr signature, whose signed message matches the transaction payload, and whose signer matches the account contract owner's public key. If the signature fails, the transaction will fail. + +For the sake of simplicity, we will hardcode the signing public key into the contract, but you could store it [in a private note](../../concepts/foundation/accounts/keys.md#using-a-private-note), [in an immutable note](../../concepts/foundation/accounts/keys.md#using-an-immutable-private-note), or [on a separate keystore](../../concepts/foundation/accounts/keys.md#using-a-separate-keystore), to mention a few examples. + +## The account contract + +Let's start with the account contract itself in Noir. Create [a new Noir contract project](../contracts/main.md) that will contain a file with the code for the account contract, with a hardcoded public key: + +#include_code contract yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/src/main.nr rust + +:::info +You can use [the Aztec CLI](../cli/main.md) to generate a new keypair if you want to use a different one: +```bash +$ aztec-cli generate-private-key +``` + +```bash +Private Key: 0xc06461a031058f116f087bc0161b11c039648eb47e03bad3eab089709bf9b8ae +Public Key: 0x0ede151adaef1cfcc1b3e152ea39f00c5cda3f3857cef00decb049d283672dc713c0e184340407e796411f74b7383252f1406272b58fccad6fee203f8a6db474 +``` +::: + +The important part of this contract is the `entrypoint` function, which will be the first function executed in any transaction originated from this account. This function has two main responsibilities: 1) authenticating the transaction and 2) executing calls. It receives a `payload` with the list of function calls to execute, as well as a signature over that payload. + +#include_code entrypoint-struct yarn-project/noir-libs/noir-aztec/src/entrypoint.nr rust + +:::info +Using the `EntrypointPayload` struct is not mandatory. You can package the instructions to be carried out by your account contract however you want. However, the entrypoint payload already provides a set of helper functions, both in Noir and Typescript, that can save you a lot of time when writing a new account contract. +::: + +Let's go step by step into what the `entrypoint` function is doing: + +#include_code entrypoint-init yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/src/main.nr rust + +We first initialise the private function context using the provided arguments, as we do in any other function. We use a `BoundedVec` container to make it easy to collect the arguments into a single array to be hashed, but we could also have assembled it manually. + +#include_code entrypoint-auth yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/src/main.nr rust + +Next is authenticating the transaction. To do this, we serialise and Pedersen-hash the payload, which contains the instructions to be carried out along with a nonce. We then assert that the signature verifies against the resulting hash and the contract public key. This makes a transaction with an invalid signature unprovable. + +#include_code entrypoint-auth yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/src/main.nr rust + +Last, we execute the calls in the payload struct. The `execute_calls` helper function runs through the private and public calls included in the entrypoint payload and executes them: + +#include_code entrypoint-execute-calls yarn-project/noir-libs/noir-aztec/src/entrypoint.nr rust + +Note the usage of the `_with_packed_args` variant of [`call_public_function` and `call_private_function`](../contracts/functions.md#calling-functions). Due to Noir limitations, we cannot include more than a small number of arguments in a function call. However, we can bypass this restriction by using a hash of the arguments in a function call, which gets automatically expanded to the full set of arguments when the nested call is executed. We call this _argument packing_. + +## The typescript side of things + +Now that we have a valid Noir account contract, we need to write the typescript glue code that will take care of formatting and authenticating transactions so they can be processed by our contract, as well as deploying the contract during account setup. This takes the form of implementing the `AccountContract` interface: + +#include_code account-contract-interface yarn-project/aztec.js/src/account/contract/index.ts typescript + +The most interesting bit here is creating an `Entrypoint`, which is the piece of code that converts from a list of function calls requested by the user into a transaction execution request that can be simulated and proven: + +#include_code entrypoint-interface yarn-project/aztec.js/src/account/entrypoint/index.ts typescript + +For our account contract, we need to assemble the function calls into a payload, sign it using Schnorr, and encode these arguments for our `entrypoint` function. Let's see how it would look like: + +#include_code account-contract yarn-project/end-to-end/src/guides/writing_an_account_contract.test.ts typescript + +Note that we are using the `buildPayload` and `hashPayload` helpers for assembling and Pedersen-hashing the `EntrypointPayload` struct for our `entrypoint` function. As mentioned, this is not required, and you can define your own structure for instructing your account contract what functions to run. + +Then, we are using the `Schnorr` signer from the `@aztec/circuits.js` package to sign over the payload hash. This signer maps to exactly the same signing scheme that Noir's standard library expects in `schnorr::verify_signature`. + +:::info +More signing schemes are available in case you want to experiment with other types of keys. Check out Noir's [documentation on cryptographic primitives](https://noir-lang.org/standard_library/cryptographic_primitives). +::: + +Last, we use the `buildTxExecutionRequest` helper function to assemble the transaction execution request from the arguments and entrypoint. Note that we are also including the set of packed arguments that map to each of the nested calls: these are required for unpacking the arguments in functions calls executed via `_with_packed_args`. + +## Trying it out + +Let's try creating a new account backed by our account contract, and interact with a simple token contract to test it works. + + +To create and deploy the account, we will use the `Account` class, which takes an instance of an Aztec RPC server, a [privacy private key](../../concepts/foundation/accounts/keys.md#privacy-keys), and an instance of our `AccountContract` class: + +#include_code account-contract-deploy yarn-project/end-to-end/src/guides/writing_an_account_contract.test.ts typescript + +Note that we get a [`Wallet` instance](./main.md) out of the account, which we can use for initialising the token contract class after deployment, so any transactions sent to it are sent from our wallet. We can then send a transaction to it and check its effects: + +#include_code account-contract-works yarn-project/end-to-end/src/guides/writing_an_account_contract.test.ts typescript + +If we run this, we get `Balance of wallet is now 150`, which shows that the `mint` call was successfully executed from our account contract. + +To make sure that we are actually validating the provided signature in our account contract, we can try signing with a different key. To do this, we will set up a new `Account` instance pointing to the contract we already deployed but using a wrong signing key: + +#include_code account-contract-fails yarn-project/end-to-end/src/guides/writing_an_account_contract.test.ts typescript + +Lo and behold, we get `Error: Assertion failed: 'verification == true'` when running the snippet above, pointing to the line in our account contract where we verify the Schnorr signature. \ No newline at end of file diff --git a/yarn-project/aztec.js/src/account/contract/index.ts b/yarn-project/aztec.js/src/account/contract/index.ts index fffb64f3161c..5f34e55c34ba 100644 --- a/yarn-project/aztec.js/src/account/contract/index.ts +++ b/yarn-project/aztec.js/src/account/contract/index.ts @@ -7,14 +7,19 @@ export * from './ecdsa_account_contract.js'; export * from './schnorr_account_contract.js'; export * from './single_key_account_contract.js'; +// docs:start:account-contract-interface /** - * An account contract instance. Knows its ABI, deployment arguments, and to create transaction execution - * requests out of function calls through an entrypoint. + * An account contract instance. Knows its ABI, deployment arguments, and to create + * transaction execution requests out of function calls through an entrypoint. */ export interface AccountContract { - /** Returns the ABI of this account contract. */ + /** + * Returns the ABI of this account contract. + */ getContractAbi(): ContractAbi; - /** Returns the deployment arguments for this instance. */ + /** + * Returns the deployment arguments for this instance. + */ getDeploymentArgs(): Promise; /** * Creates an entrypoint for creating transaction execution requests for this account contract. @@ -23,3 +28,4 @@ export interface AccountContract { */ getEntrypoint(address: CompleteAddress, nodeInfo: NodeInfo): Promise; } +// docs:end:account-contract-interface diff --git a/yarn-project/aztec.js/src/account/entrypoint/entrypoint_payload.ts b/yarn-project/aztec.js/src/account/entrypoint/entrypoint_payload.ts index b5ec1c719c24..0a6dc8b15590 100644 --- a/yarn-project/aztec.js/src/account/entrypoint/entrypoint_payload.ts +++ b/yarn-project/aztec.js/src/account/entrypoint/entrypoint_payload.ts @@ -1,12 +1,13 @@ import { CircuitsWasm, Fr, GeneratorIndex } from '@aztec/circuits.js'; import { pedersenPlookupCompressWithHashIndex } from '@aztec/circuits.js/barretenberg'; import { padArrayEnd } from '@aztec/foundation/collection'; -import { IWasmModule } from '@aztec/foundation/wasm'; import { FunctionCall, PackedArguments, emptyFunctionCall } from '@aztec/types'; +import partition from 'lodash.partition'; + // These must match the values defined in yarn-project/noir-libs/noir-aztec/src/entrypoint.nr -const ACCOUNT_MAX_PRIVATE_CALLS = 2; -const ACCOUNT_MAX_PUBLIC_CALLS = 2; +export const ACCOUNT_MAX_PRIVATE_CALLS = 2; +export const ACCOUNT_MAX_PUBLIC_CALLS = 2; /** Encoded payload for the account contract entrypoint */ export type EntrypointPayload = { @@ -24,10 +25,7 @@ export type EntrypointPayload = { }; /** Assembles an entrypoint payload from a set of private and public function calls */ -export async function buildPayload( - privateCalls: FunctionCall[], - publicCalls: FunctionCall[], -): Promise<{ +export async function buildPayload(calls: FunctionCall[]): Promise<{ /** The payload for the entrypoint function */ payload: EntrypointPayload; /** The packed arguments of functions called */ @@ -35,7 +33,9 @@ export async function buildPayload( }> { const nonce = Fr.random(); - const calls = [ + const [privateCalls, publicCalls] = partition(calls, call => call.functionData.isPrivate); + + const paddedCalls = [ ...padArrayEnd(privateCalls, emptyFunctionCall(), ACCOUNT_MAX_PRIVATE_CALLS), ...padArrayEnd(publicCalls, emptyFunctionCall(), ACCOUNT_MAX_PUBLIC_CALLS), ]; @@ -43,7 +43,7 @@ export async function buildPayload( const packedArguments = []; const wasm = await CircuitsWasm.get(); - for (const call of calls) { + for (const call of paddedCalls) { packedArguments.push(await PackedArguments.fromArgs(call.args, wasm)); } @@ -52,9 +52,9 @@ export async function buildPayload( // eslint-disable-next-line camelcase flattened_args_hashes: packedArguments.map(args => args.hash), // eslint-disable-next-line camelcase - flattened_selectors: calls.map(call => call.functionData.selector.toField()), + flattened_selectors: paddedCalls.map(call => call.functionData.selector.toField()), // eslint-disable-next-line camelcase - flattened_targets: calls.map(call => call.to.toField()), + flattened_targets: paddedCalls.map(call => call.to.toField()), nonce, }, packedArguments, @@ -62,9 +62,9 @@ export async function buildPayload( } /** Compresses an entrypoint payload to a 32-byte buffer (useful for signing) */ -export function hashPayload(payload: EntrypointPayload, wasm: IWasmModule) { +export async function hashPayload(payload: EntrypointPayload) { return pedersenPlookupCompressWithHashIndex( - wasm, + await CircuitsWasm.get(), flattenPayload(payload).map(fr => fr.toBuffer()), GeneratorIndex.SIGNATURE_PAYLOAD, ); diff --git a/yarn-project/aztec.js/src/account/entrypoint/entrypoint_utils.ts b/yarn-project/aztec.js/src/account/entrypoint/entrypoint_utils.ts new file mode 100644 index 000000000000..282e98386646 --- /dev/null +++ b/yarn-project/aztec.js/src/account/entrypoint/entrypoint_utils.ts @@ -0,0 +1,31 @@ +import { AztecAddress, FunctionData, TxContext } from '@aztec/circuits.js'; +import { FunctionAbi, encodeArguments } from '@aztec/foundation/abi'; +import { NodeInfo, PackedArguments, TxExecutionRequest } from '@aztec/types'; + +/** + * Utility for building a TxExecutionRequest in the context of an Entrypoint. + * @param origin - Address of the account contract sending this transaction. + * @param entrypointMethod - Initial method called in the account contract. + * @param args - Arguments used when calling this initial method. + * @param callsPackedArguments - Packed arguments of nested calls (if any). + * @param nodeInfo - Node info with chain id and version. + * @returns A TxExecutionRequest ready to be simulated, proven, and sent. + */ +export async function buildTxExecutionRequest( + origin: AztecAddress, + entrypointMethod: FunctionAbi, + args: any[], + callsPackedArguments: PackedArguments[], + nodeInfo: NodeInfo, +): Promise { + const packedArgs = await PackedArguments.fromArgs(encodeArguments(entrypointMethod, args)); + const { chainId, version } = nodeInfo; + + return TxExecutionRequest.from({ + argsHash: packedArgs.hash, + origin, + functionData: FunctionData.fromAbi(entrypointMethod), + txContext: TxContext.empty(chainId, version), + packedArguments: [...callsPackedArguments, packedArgs], + }); +} diff --git a/yarn-project/aztec.js/src/account/entrypoint/index.ts b/yarn-project/aztec.js/src/account/entrypoint/index.ts index 00168fededb4..5a6cae18cb7a 100644 --- a/yarn-project/aztec.js/src/account/entrypoint/index.ts +++ b/yarn-project/aztec.js/src/account/entrypoint/index.ts @@ -2,6 +2,8 @@ import { AztecAddress } from '@aztec/circuits.js'; import { FunctionCall, TxExecutionRequest } from '@aztec/types'; export * from './entrypoint_collection.js'; +export * from './entrypoint_payload.js'; +export * from './entrypoint_utils.js'; export * from './single_key_account_entrypoint.js'; export * from './stored_key_account_entrypoint.js'; @@ -17,6 +19,7 @@ export type CreateTxRequestOpts = { * Knows how to assemble a transaction execution request given a set of function calls. */ export interface Entrypoint { + // docs:start:entrypoint-interface /** * Generates an authenticated request out of set of intents * @param executions - The execution intents to be run. @@ -24,5 +27,6 @@ export interface Entrypoint { * @returns The authenticated transaction execution request. */ createTxExecutionRequest(executions: FunctionCall[], opts?: CreateTxRequestOpts): Promise; + // docs:end:entrypoint-interface } // docs:end:entrypoint-interface diff --git a/yarn-project/aztec.js/src/account/entrypoint/single_key_account_entrypoint.ts b/yarn-project/aztec.js/src/account/entrypoint/single_key_account_entrypoint.ts index 28b6dbf3a146..9e500d715b35 100644 --- a/yarn-project/aztec.js/src/account/entrypoint/single_key_account_entrypoint.ts +++ b/yarn-project/aztec.js/src/account/entrypoint/single_key_account_entrypoint.ts @@ -1,10 +1,8 @@ -import { AztecAddress, CircuitsWasm, FunctionData, PartialAddress, PrivateKey, TxContext } from '@aztec/circuits.js'; +import { AztecAddress, FunctionData, PartialAddress, PrivateKey, TxContext } from '@aztec/circuits.js'; import { Signer } from '@aztec/circuits.js/barretenberg'; import { ContractAbi, encodeArguments } from '@aztec/foundation/abi'; import { FunctionCall, PackedArguments, TxExecutionRequest } from '@aztec/types'; -import partition from 'lodash.partition'; - import SchnorrSingleKeyAccountContractAbi from '../../abis/schnorr_single_key_account_contract.json' assert { type: 'json' }; import { generatePublicKey } from '../../index.js'; import { DEFAULT_CHAIN_ID, DEFAULT_VERSION } from '../../utils/defaults.js'; @@ -34,16 +32,14 @@ export class SingleKeyAccountEntrypoint implements Entrypoint { throw new Error(`Sender ${opts.origin.toString()} does not match account address ${this.address.toString()}`); } - const [privateCalls, publicCalls] = partition(executions, exec => exec.functionData.isPrivate); - const wasm = await CircuitsWasm.get(); - const { payload, packedArguments: callsPackedArguments } = await buildPayload(privateCalls, publicCalls); - const message = hashPayload(payload, wasm); + const { payload, packedArguments: callsPackedArguments } = await buildPayload(executions); + const message = await hashPayload(payload); const signature = this.signer.constructSignature(message, this.privateKey).toBuffer(); const publicKey = await generatePublicKey(this.privateKey); const args = [payload, publicKey.toBuffer(), signature, this.partialAddress]; const abi = this.getEntrypointAbi(); - const packedArgs = await PackedArguments.fromArgs(encodeArguments(abi, args), wasm); + const packedArgs = await PackedArguments.fromArgs(encodeArguments(abi, args)); const txRequest = TxExecutionRequest.from({ argsHash: packedArgs.hash, origin: this.address, diff --git a/yarn-project/aztec.js/src/account/entrypoint/stored_key_account_entrypoint.ts b/yarn-project/aztec.js/src/account/entrypoint/stored_key_account_entrypoint.ts index 0b5444d50bb5..f86e04d9095d 100644 --- a/yarn-project/aztec.js/src/account/entrypoint/stored_key_account_entrypoint.ts +++ b/yarn-project/aztec.js/src/account/entrypoint/stored_key_account_entrypoint.ts @@ -1,11 +1,9 @@ -import { AztecAddress, CircuitsWasm, FunctionData, PrivateKey, TxContext } from '@aztec/circuits.js'; +import { AztecAddress, FunctionData, PrivateKey, TxContext } from '@aztec/circuits.js'; import { Signer } from '@aztec/circuits.js/barretenberg'; import { ContractAbi, encodeArguments } from '@aztec/foundation/abi'; import { DebugLogger, createDebugLogger } from '@aztec/foundation/log'; import { FunctionCall, PackedArguments, TxExecutionRequest } from '@aztec/types'; -import partition from 'lodash.partition'; - import EcdsaAccountContractAbi from '../../abis/ecdsa_account_contract.json' assert { type: 'json' }; import { DEFAULT_CHAIN_ID, DEFAULT_VERSION } from '../../utils/defaults.js'; import { buildPayload, hashPayload } from './entrypoint_payload.js'; @@ -36,16 +34,14 @@ export class StoredKeyAccountEntrypoint implements Entrypoint { throw new Error(`Sender ${opts.origin.toString()} does not match account address ${this.address.toString()}`); } - const [privateCalls, publicCalls] = partition(executions, exec => exec.functionData.isPrivate); - const wasm = await CircuitsWasm.get(); - const { payload, packedArguments: callsPackedArguments } = await buildPayload(privateCalls, publicCalls); - const message = hashPayload(payload, wasm); + const { payload, packedArguments: callsPackedArguments } = await buildPayload(executions); + const message = await hashPayload(payload); const signature = this.signer.constructSignature(message, this.privateKey).toBuffer(); this.log(`Signed challenge ${message.toString('hex')} as ${signature.toString('hex')}`); const args = [payload, signature]; const abi = this.getEntrypointAbi(); - const packedArgs = await PackedArguments.fromArgs(encodeArguments(abi, args), wasm); + const packedArgs = await PackedArguments.fromArgs(encodeArguments(abi, args)); const txRequest = TxExecutionRequest.from({ argsHash: packedArgs.hash, origin: this.address, diff --git a/yarn-project/end-to-end/src/guides/writing_an_account_contract.test.ts b/yarn-project/end-to-end/src/guides/writing_an_account_contract.test.ts new file mode 100644 index 000000000000..a5903e5d3446 --- /dev/null +++ b/yarn-project/end-to-end/src/guides/writing_an_account_contract.test.ts @@ -0,0 +1,121 @@ +import { AztecRPCServer } from '@aztec/aztec-rpc'; +import { + Account, + AccountContract, + CompleteAddress, + CreateTxRequestOpts, + Entrypoint, + FunctionCall, + NodeInfo, + buildPayload, + buildTxExecutionRequest, + hashPayload, +} from '@aztec/aztec.js'; +import { PrivateKey } from '@aztec/circuits.js'; +import { Schnorr } from '@aztec/circuits.js/barretenberg'; +import { ContractAbi } from '@aztec/foundation/abi'; +import { PrivateTokenContract, SchnorrHardcodedAccountContractAbi } from '@aztec/noir-contracts/types'; + +import { setup } from '../fixtures/utils.js'; + +// docs:start:account-contract +const PRIVATE_KEY = PrivateKey.fromString('0xff2b5f8212061f0e074fc8794ffe8524130434889df20a912d7329e03894ccff'); + +/** Account contract implementation that authenticates txs using Schnorr signatures. */ +class SchnorrHardcodedKeyAccountContract implements AccountContract { + constructor(private privateKey: PrivateKey = PRIVATE_KEY) {} + + getContractAbi(): ContractAbi { + // Return the ABI of the SchnorrHardcodedAccount contract. + return SchnorrHardcodedAccountContractAbi; + } + + getDeploymentArgs(): Promise { + // This contract does not require any arguments in its constructor. + return Promise.resolve([]); + } + + getEntrypoint(completeAddress: CompleteAddress, nodeInfo: NodeInfo): Promise { + const privateKey = this.privateKey; + const address = completeAddress.address; + + // Create a new Entrypoint object, whose responsibility is to turn function calls from the user + // into a tx execution request ready to be simulated and sent. + return Promise.resolve({ + async createTxExecutionRequest(calls: FunctionCall[], opts: CreateTxRequestOpts = {}) { + // Validate that the requested origin matches (if set) + if (opts.origin && !opts.origin.equals(address)) { + throw new Error(`Sender ${opts.origin.toString()} does not match ${address.toString()}`); + } + + // Assemble the EntrypointPayload out of the requested calls + const { payload, packedArguments: callsPackedArguments } = await buildPayload(calls); + + // Hash the request payload and sign it using Schnorr + const message = await hashPayload(payload); + const signer = await Schnorr.new(); + const signature = signer.constructSignature(message, privateKey).toBuffer(); + + // Collect the payload and its signature as arguments to the entrypoint + const args = [payload, signature]; + + // Capture the entrypoint function + const entrypointMethod = SchnorrHardcodedAccountContractAbi.functions.find(f => f.name === 'entrypoint')!; + + // Assemble and return the tx execution request + return buildTxExecutionRequest(address, entrypointMethod, args, callsPackedArguments, nodeInfo); + }, + }); + } +} +// docs:end:account-contract + +describe('guides/writing_an_account_contract', () => { + let context: Awaited>; + + beforeEach(async () => { + context = await setup(0); + }, 60_000); + + afterEach(async () => { + await context.aztecNode?.stop(); + if (context.aztecRpcServer instanceof AztecRPCServer) { + await context.aztecRpcServer.stop(); + } + }); + + it('works', async () => { + const { aztecRpcServer: rpc, logger } = context; + // docs:start:account-contract-deploy + const encryptionPrivateKey = PrivateKey.random(); + const account = new Account(rpc, encryptionPrivateKey, new SchnorrHardcodedKeyAccountContract()); + const wallet = await account.waitDeploy(); + const address = wallet.getCompleteAddress().address; + // docs:end:account-contract-deploy + logger(`Deployed account contract at ${address}`); + + // docs:start:account-contract-works + const token = await PrivateTokenContract.deploy(wallet, 100, address).send().deployed(); + logger(`Deployed token contract at ${token.address}`); + + await token.methods.mint(50, address).send().wait(); + const balance = await token.methods.getBalance(address).view(); + logger(`Balance of wallet is now ${balance}`); + // docs:end:account-contract-works + expect(balance).toEqual(150n); + + // docs:start:account-contract-fails + const wrongKey = PrivateKey.random(); + const wrongAccountContract = new SchnorrHardcodedKeyAccountContract(wrongKey); + const wrongAccount = new Account(rpc, encryptionPrivateKey, wrongAccountContract, wallet.getCompleteAddress()); + const wrongWallet = await wrongAccount.getWallet(); + const tokenWithWrongWallet = await PrivateTokenContract.at(token.address, wrongWallet); + + try { + await tokenWithWrongWallet.methods.mint(200, address).simulate(); + } catch (err) { + logger(`Failed to send tx: ${err}`); + } + // docs:end:account-contract-fails + }, 60_000); +}); diff --git a/yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/Nargo.toml b/yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/Nargo.toml new file mode 100644 index 000000000000..4c754d368efc --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "schnorr_hardcoded_account_contract" +authors = [""] +compiler_version = "0.1" +type = "contract" + +[dependencies] +aztec = { path = "../../../../noir-libs/noir-aztec" } \ No newline at end of file diff --git a/yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/src/main.nr b/yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/src/main.nr new file mode 100644 index 000000000000..37c243f3244a --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/schnorr_hardcoded_account_contract/src/main.nr @@ -0,0 +1,56 @@ +// docs:start:contract +// Account contract that uses Schnorr signatures for authentication using a hardcoded public key. +contract SchnorrHardcodedAccount { + global public_key_x: Field = 0x077a724f70dfb200eae8951b27aebb5c97629eb03224b397b109d09509f978a4; + global public_key_y: Field = 0x0f0aad1ece7d55d177d4b44fd28f53bfdc0978be15939ce8762f71db88f37774; + + use dep::std; + use dep::aztec::{ + entrypoint::{ EntrypointPayload, ENTRYPOINT_PAYLOAD_SIZE }, + abi::{ PrivateCircuitPublicInputs, PrivateContextInputs, hash_args }, + types::{ vec::BoundedVec, point::Point }, + constants_gen::GENERATOR_INDEX__SIGNATURE_PAYLOAD, + context::PrivateContext, + }; + + // Entrypoint for authenticating and executing calls from this account + fn entrypoint( + inputs: pub PrivateContextInputs, + payload: pub EntrypointPayload, // contains a set of arguments, selectors, targets and a nonce + signature: pub [u8;64], // schnorr signature of the payload hash + ) -> distinct pub PrivateCircuitPublicInputs { + // docs:start:entrypoint-init + // Initialize context (args len is ENTRYPOINT_PAYLOAD_SIZE + 64) + let mut args: BoundedVec = BoundedVec::new(0); + args.push_array(payload.serialize()); + for byte in signature { args.push(byte as Field); } + let mut context = PrivateContext::new(inputs, hash_args(args.storage)); + // docs:end:entrypoint-init + + // docs:start:entrypoint-auth + // Verify payload signature + let serialised_payload: [Field; ENTRYPOINT_PAYLOAD_SIZE] = payload.serialize(); + let hashed_payload: Field = std::hash::pedersen_with_separator(serialised_payload, GENERATOR_INDEX__SIGNATURE_PAYLOAD)[0]; + + // TODO: Workaround for https://github.com/noir-lang/noir/issues/2421 + let message_bytes_slice = hashed_payload.to_be_bytes(32); + let mut message_bytes: [u8; 32] = [0; 32]; + for i in 0..32 { message_bytes[i] = message_bytes_slice[i]; } + + let verification = std::schnorr::verify_signature(public_key_x, public_key_y, signature, message_bytes); + assert(verification == true); + // docs:end:entrypoint-auth + + // docs:start:entrypoint-exec + // Execute calls + payload.execute_calls(&mut context); + context.finish() + // docs:end:entrypoint-exec + } + + // Constructs the contract + fn constructor(inputs: pub PrivateContextInputs) -> distinct pub PrivateCircuitPublicInputs { + PrivateContext::new(inputs, 0).finish() + } +} +// docs:end:contract \ No newline at end of file diff --git a/yarn-project/noir-libs/noir-aztec/src/entrypoint.nr b/yarn-project/noir-libs/noir-aztec/src/entrypoint.nr index efb4c9aa1f60..eab9a420bccb 100644 --- a/yarn-project/noir-libs/noir-aztec/src/entrypoint.nr +++ b/yarn-project/noir-libs/noir-aztec/src/entrypoint.nr @@ -26,13 +26,14 @@ impl FunctionCall { global ENTRYPOINT_PAYLOAD_SIZE: Field = 13; global ENTRYPOINT_PAYLOAD_SIZE_IN_BYTES: Field = 416; +// docs:start:entrypoint-struct struct EntrypointPayload { - // Noir doesnt support nested arrays or structs yet so we flatten everything flattened_args_hashes: [Field; ACCOUNT_MAX_CALLS], flattened_selectors: [Field; ACCOUNT_MAX_CALLS], flattened_targets: [Field; ACCOUNT_MAX_CALLS], nonce: Field, } +// docs:end:entrypoint-struct impl EntrypointPayload { // TODO(#1207) Do we need a generator index? @@ -88,6 +89,7 @@ impl EntrypointPayload { } // Executes all private and public calls + // docs:start:entrypoint-execute-calls fn execute_calls(self, context: &mut PrivateContext) { for i in 0..ACCOUNT_MAX_PRIVATE_CALLS { let target_address = self.flattened_targets[i]; @@ -106,4 +108,5 @@ impl EntrypointPayload { } } } + // docs:end:entrypoint-execute-calls } \ No newline at end of file