Skip to content

Commit

Permalink
Write guide
Browse files Browse the repository at this point in the history
  • Loading branch information
spalladino committed Aug 24, 2023
1 parent abf34d3 commit 2222a56
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 29 deletions.
89 changes: 88 additions & 1 deletion docs/docs/dev_docs/wallets/writing_an_account_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,91 @@ Writing your own account contract allows you to define the rules by which user t

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/).

- ECDSA with hardcoded pubkey using noble for signing
For this tutorial, we will write an account contract that uses Schnorr signatures for authenticating transaction requests. 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

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 sent from this account. This function has two main responsibilities: authenticating the transaction and executing any calls. It receives a `payload` with the list of function calls to execute, and 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 an account contract.
:::

Let's go step by step into the `entrypoint` function:

#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 actually execute the calls in the payload struct. The `execute_calls` 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_, and it will be especially relevant when writing the typescript side of things!

## 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 it. 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, and assembled into a transaction to be sent to the network:

#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 the 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.

<!-- TODO: Link to docs showing how to get an instance of Aztec RPC server -->
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, 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` transaction was successfully executed.

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.
14 changes: 10 additions & 4 deletions yarn-project/aztec.js/src/account/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any[]>;
/**
* Creates an entrypoint for creating transaction execution requests for this account contract.
Expand All @@ -23,3 +28,4 @@ export interface AccountContract {
*/
getEntrypoint(address: CompleteAddress, nodeInfo: NodeInfo): Promise<Entrypoint>;
}
// docs:end:account-contract-interface
2 changes: 2 additions & 0 deletions yarn-project/aztec.js/src/account/entrypoint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ 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.
* @param opts - Options.
* @returns The authenticated transaction execution request.
*/
createTxExecutionRequest(executions: FunctionCall[], opts?: CreateTxRequestOpts): Promise<TxExecutionRequest>;
// docs:end:entrypoint-interface
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import {
buildTxExecutionRequest,
hashPayload,
} from '@aztec/aztec.js';
import { CircuitsWasm, PrivateKey } from '@aztec/circuits.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. */
Expand Down Expand Up @@ -52,7 +53,7 @@ class SchnorrHardcodedKeyAccountContract implements AccountContract {

// Hash the request payload and sign it using Schnorr
const message = await hashPayload(payload);
const signer = new Schnorr(await CircuitsWasm.get());
const signer = await Schnorr.new();
const signature = signer.constructSignature(message, privateKey).toBuffer();

// Collect the payload and its signature as arguments to the entrypoint
Expand All @@ -67,6 +68,7 @@ class SchnorrHardcodedKeyAccountContract implements AccountContract {
});
}
}
// docs:end:account-contract

describe('guides/writing_an_account_contract', () => {
let context: Awaited<ReturnType<typeof setup>>;
Expand All @@ -84,31 +86,36 @@ describe('guides/writing_an_account_contract', () => {

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 walletAddress = wallet.getCompleteAddress().address;
logger(`Deployed account contract at ${walletAddress}`);
const address = wallet.getCompleteAddress().address;
// docs:end:account-contract-deploy
logger(`Deployed account contract at ${address}`);

const token = await PrivateTokenContract.deploy(wallet, 100, walletAddress).send().deployed();
// 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, walletAddress).send().wait();
const balance = await token.methods.getBalance(walletAddress).view();
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, walletAddress).simulate();
await tokenWithWrongWallet.methods.mint(200, address).simulate();
} catch (err) {
logger(`Failed to send tx: ${err}`);
}
// docs:end:account-contract-fails
}, 60_000);
});
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
// 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,
},
entrypoint::{ EntrypointPayload, ENTRYPOINT_PAYLOAD_SIZE },
abi::{ PrivateCircuitPublicInputs, PrivateContextInputs, hash_args },
types::{ vec::BoundedVec, point::Point },
constants_gen::GENERATOR_INDEX__SIGNATURE_PAYLOAD,
context::PrivateContext,
};
Expand All @@ -28,26 +19,32 @@ contract SchnorrHardcodedAccount {
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<Field, 77> = 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];
let verification = std::schnorr::verify_signature(public_key_x, public_key_y, signature, hashed_payload.to_be_bytes(32));
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
5 changes: 4 additions & 1 deletion yarn-project/noir-libs/noir-aztec/src/entrypoint.nr
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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];
Expand All @@ -106,4 +108,5 @@ impl EntrypointPayload {
}
}
}
// docs:end:entrypoint-execute-calls
}

0 comments on commit 2222a56

Please sign in to comment.