diff --git a/.circleci/config.yml b/.circleci/config.yml index 89d875c64f18..476614635fb8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -737,6 +737,17 @@ jobs: name: "Test" command: cond_run_script end-to-end ./scripts/run_tests_local e2e_token_contract.test.ts + e2e-token-bridge-contract: + machine: + image: ubuntu-2004:202010-01 + resource_class: large + steps: + - *checkout + - *setup_env + - run: + name: "Test" + command: cond_run_script end-to-end ./scripts/run_tests_local e2e_token_bridge_contract.test.ts + e2e-private-token-contract: machine: image: ubuntu-2004:202010-01 @@ -1459,6 +1470,7 @@ workflows: - e2e-deploy-contract: *e2e_test - e2e-lending-contract: *e2e_test - e2e-token-contract: *e2e_test + - e2e-token-bridge-contract: *e2e_test - e2e-private-token-contract: *e2e_test - e2e-sandbox-example: *e2e_test - e2e-multi-transfer-contract: *e2e_test @@ -1494,6 +1506,7 @@ workflows: - e2e-deploy-contract - e2e-lending-contract - e2e-token-contract + - e2e-token-bridge-contract - e2e-private-token-contract - e2e-sandbox-example - e2e-multi-transfer-contract diff --git a/l1-contracts/test/portals/UniswapPortal.sol b/l1-contracts/test/portals/UniswapPortal.sol index 8d68ff8861ae..00abc1710f94 100644 --- a/l1-contracts/test/portals/UniswapPortal.sol +++ b/l1-contracts/test/portals/UniswapPortal.sol @@ -47,8 +47,8 @@ contract UniswapPortal { * @param _amountOutMinimum - The minimum amount of output assets to receive from the swap (slippage protection) * @param _aztecRecipient - The aztec address to receive the output assets * @param _secretHash - The hash of the secret consumable message - * @param _deadlineForL1ToL2Message - deadline for when the L1 to L2 message (to mint outpiut assets in L2) must be consumed by - * @param _canceller - The ethereum address that can cancel the deposit + * @param _deadlineForL1ToL2Message - deadline for when the L1 to L2 message (to mint output assets in L2) must be consumed by + * @param _canceller - The ethereum address that can cancel the L1 to L2 deposit * @param _withCaller - When true, using `msg.sender` as the caller, otherwise address(0) * @return The entryKey of the deposit transaction in the Inbox */ diff --git a/yarn-project/end-to-end/src/cli_docs_sandbox.test.ts b/yarn-project/end-to-end/src/cli_docs_sandbox.test.ts index 204e163fa53a..bd5f0e09fc39 100644 --- a/yarn-project/end-to-end/src/cli_docs_sandbox.test.ts +++ b/yarn-project/end-to-end/src/cli_docs_sandbox.test.ts @@ -77,7 +77,7 @@ describe('CLI docs sandbox', () => { logs.splice(0); }; - it('prints example contracts', async () => { + it.only('prints example contracts', async () => { const docs = ` // docs:start:example-contracts % aztec-cli example-contracts @@ -104,6 +104,7 @@ SchnorrAuthWitnessAccountContractAbi SchnorrHardcodedAccountContractAbi SchnorrSingleKeyAccountContractAbi TestContractAbi +TokenBridgeContractAbi TokenContractAbi UniswapContractAbi // docs:end:example-contracts diff --git a/yarn-project/end-to-end/src/e2e_token_bridge_contract.test.ts b/yarn-project/end-to-end/src/e2e_token_bridge_contract.test.ts new file mode 100644 index 000000000000..00874bad1f15 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_token_bridge_contract.test.ts @@ -0,0 +1,261 @@ +import { AztecNodeService } from '@aztec/aztec-node'; +import { AztecRPCServer } from '@aztec/aztec-rpc'; +import { AuthWitnessEntrypointWallet, computeMessageSecretHash } from '@aztec/aztec.js'; +import { + AztecAddress, + CircuitsWasm, + CompleteAddress, + EthAddress, + Fr, + FunctionSelector, + GeneratorIndex, +} from '@aztec/circuits.js'; +import { pedersenPlookupCompressWithHashIndex } from '@aztec/circuits.js/barretenberg'; +import { DeployL1Contracts } from '@aztec/ethereum'; +import { DebugLogger } from '@aztec/foundation/log'; +import { SchnorrAuthWitnessAccountContract, TokenBridgeContract, TokenContract } from '@aztec/noir-contracts/types'; +import { AztecRPC, TxStatus } from '@aztec/types'; + +import { + createAuthWitnessAccounts, + delay, + deployAndInitializeStandardizedTokenAndBridgeContracts, + setup, +} from './fixtures/utils.js'; + +const hashPayload = async (payload: Fr[]) => { + return pedersenPlookupCompressWithHashIndex( + await CircuitsWasm.get(), + payload.map(fr => fr.toBuffer()), + GeneratorIndex.SIGNATURE_PAYLOAD, + ); +}; + +describe('e2e_token_bridge_contract', () => { + let aztecNode: AztecNodeService | undefined; + let aztecRpcServer: AztecRPC; + let wallets: AuthWitnessEntrypointWallet[]; + let accounts: CompleteAddress[]; + let logger: DebugLogger; + + let ethAccount: EthAddress; + let ownerAddress: AztecAddress; + let token: TokenContract; + let bridge: TokenBridgeContract; + let tokenPortalAddress: EthAddress; + let tokenPortal: any; + let underlyingERC20: any; + + beforeAll(async () => { + let deployL1ContractsValues: DeployL1Contracts; + + ({ aztecNode, aztecRpcServer, deployL1ContractsValues, logger } = await setup(0)); + ({ accounts, wallets } = await createAuthWitnessAccounts(aztecRpcServer, 3)); + + const walletClient = deployL1ContractsValues.walletClient; + const publicClient = deployL1ContractsValues.publicClient; + ethAccount = EthAddress.fromString((await walletClient.getAddresses())[0]); + ownerAddress = accounts[0].address; + + logger(`Deploying and initializing token, portal and its bridge...`); + const contracts = await deployAndInitializeStandardizedTokenAndBridgeContracts( + wallets[0], + walletClient, + publicClient, + deployL1ContractsValues!.registryAddress, + ownerAddress, + ); + token = contracts.l2Token; + bridge = contracts.bridge; + tokenPortalAddress = contracts.tokenPortalAddress; + tokenPortal = contracts.tokenPortal; + underlyingERC20 = contracts.underlyingERC20; + logger(`Deployed and initialized token, portal and its bridge.`); + }, 65_000); + + afterEach(async () => { + await aztecNode?.stop(); + if (aztecRpcServer instanceof AztecRPCServer) { + await aztecRpcServer?.stop(); + } + }); + + // TODO(#2291) - replace with new cross chain harness impl + const mintTokensOnL1 = async (amount: bigint) => { + logger('Minting tokens on L1'); + await underlyingERC20.write.mint([ethAccount.toString(), amount], {} as any); + expect(await underlyingERC20.read.balanceOf([ethAccount.toString()])).toBe(amount); + }; + + // TODO(#2291) - replace with new cross chain harness impl + const depositTokensToPortal = async (bridgeAmount: bigint, secretHash: Fr) => { + await underlyingERC20.write.approve([tokenPortalAddress.toString(), bridgeAmount], {} as any); + + // Deposit tokens to the TokenPortal + const deadline = 2 ** 32 - 1; // max uint32 - 1 + logger('Sending messages to L1 portal to be consumed'); + const args = [ + ownerAddress.toString(), + bridgeAmount, + deadline, + secretHash.toString(true), + ethAccount.toString(), + ] as const; + const { result: messageKeyHex } = await tokenPortal.simulate.depositToAztec(args, { + account: ethAccount.toString(), + } as any); + await tokenPortal.write.depositToAztec(args, {} as any); + + return Fr.fromString(messageKeyHex); + }; + + const mintPublicOnL2 = async (amount: bigint) => { + const tx = token.methods.mint_public({ address: ownerAddress }, amount).send(); + const receipt = await tx.wait(); + expect(receipt.status).toBe(TxStatus.MINED); + }; + + it('Deposit funds (publicly) from L1 -> L2 and withdraw (publicly) back to L1', async () => { + const l1TokenBalance = 1000000n; + const bridgeAmount = 100n; + const secret = Fr.random(); + const secretHash = await computeMessageSecretHash(secret); + + // 1. Mint tokens on L1 + await mintTokensOnL1(l1TokenBalance); + + // 2. Deposit tokens to the TokenPortal + const messageKey = await depositTokensToPortal(bridgeAmount, secretHash); + expect(await underlyingERC20.read.balanceOf([ethAccount.toString()])).toBe(l1TokenBalance - bridgeAmount); + + // Wait for the archiver to process the message + await delay(5000); /// waiting 5 seconds. + + // Perform an unrelated transaction on L2 to progress the rollup - here we mint tokens to owner + const amount = 99n; + await mintPublicOnL2(amount); + const balanceBefore = await token.methods.balance_of_public({ address: ownerAddress }).view(); + expect(balanceBefore).toBe(amount); + + // 3. Consume message on aztec and mint publicly + logger('Consuming messages on L2'); + const tx = bridge.methods + .deposit_public(bridgeAmount, messageKey, secret, { address: ethAccount.toField() }) + .send(); + const receipt = await tx.wait(); + expect(receipt.status).toBe(TxStatus.MINED); + const afterBalance = await token.methods.balance_of_public({ address: ownerAddress }).view(); + expect(afterBalance).toBe(balanceBefore + bridgeAmount); + + // time to withdraw the funds again! + logger('Withdrawing funds from L2'); + + // 4. Give approval to bridge to burn owner's funds: + const withdrawAmount = 9n; + const nonce = Fr.random(); + + const burnMessageHash = async (caller: AztecAddress, from: AztecAddress, amount: bigint, nonce: Fr) => { + return await hashPayload([ + caller.toField(), + token.address.toField(), + FunctionSelector.fromSignature('burn_public((Field),Field,Field)').toField(), + from.toField(), + new Fr(amount), + nonce, + ]); + }; + + const messageHash = await burnMessageHash(bridge.address, ownerAddress, withdrawAmount, nonce); + // Add it to the wallet as approved + const owner = await SchnorrAuthWitnessAccountContract.at(ownerAddress, wallets[0]); + const setValidTx = owner.methods.set_is_valid_storage(messageHash, 1).send(); + const validTxReceipt = await setValidTx.wait(); + expect(validTxReceipt.status).toBe(TxStatus.MINED); + + // 5. Withdraw from L2 bridge + const withdrawTx = bridge.methods + .withdraw_public({ address: ethAccount.toField() }, withdrawAmount, { address: EthAddress.ZERO.toField() }, nonce) + .send(); + const withdrawReceipt = await withdrawTx.wait(); + expect(withdrawReceipt.status).toBe(TxStatus.MINED); + expect(await token.methods.balance_of_public({ address: ownerAddress }).view()).toBe(afterBalance - withdrawAmount); + + // TODO (#2291): Consume message on L1 -> update crosschain test harness to do this cleanly. + }, 120_000); + + it('Deposit funds (privately) from L1 -> L2 and withdraw (privately) back to L1', async () => { + const l1TokenBalance = 1000000n; + const bridgeAmount = 100n; + const secret = Fr.random(); + const secretHash = await computeMessageSecretHash(secret); + + // 1. Mint tokens on L1 + // TODO (#2291): Because same owner is used across two tests, l1 balance already exists. This is why we have two separate cross chain tests. + // Separate them like before + if ((await underlyingERC20.read.balanceOf([ethAccount.toString()])) === 0n) { + await mintTokensOnL1(l1TokenBalance); + } + + // 2. Deposit tokens to the TokenPortal + const messageKey = await depositTokensToPortal(bridgeAmount, secretHash); + expect(await underlyingERC20.read.balanceOf([ethAccount.toString()])).toBe(l1TokenBalance - bridgeAmount); + + // Wait for the archiver to process the message + await delay(5000); /// waiting 5 seconds. + + // Perform an unrelated transaction on L2 to progress the rollup - here we mint tokens to owner + const amount = 99n; + await mintPublicOnL2(amount); + const balanceBefore = await token.methods.balance_of_public({ address: ownerAddress }).view(); + expect(balanceBefore).toBe(amount); + + // 3. Consume message on aztec and mint publicly + logger('Consuming messages on L2'); + const tx = bridge.methods.deposit(bridgeAmount, messageKey, secret, { address: ethAccount.toField() }).send(); + const receipt = await tx.wait(); + expect(receipt.status).toBe(TxStatus.MINED); + const txClaim = token.methods.redeem_shield({ address: ownerAddress }, bridgeAmount, secret).send(); + const receiptClaim = await txClaim.wait(); + expect(receiptClaim.status).toBe(TxStatus.MINED); + + const afterPrivateBalance = await token.methods.balance_of_private({ address: ownerAddress }).view(); + expect(afterPrivateBalance).toBe(bridgeAmount); + + // time to withdraw the funds again! + logger('Withdrawing funds from L2'); + + // 4. Give approval to bridge to burn owner's funds: + const withdrawAmount = 9n; + const nonce = Fr.random(); + const burnMessageHash = async (caller: AztecAddress, from: AztecAddress, amount: bigint, nonce: Fr) => { + return await hashPayload([ + caller.toField(), + token.address.toField(), + FunctionSelector.fromSignature('burn((Field),Field,Field)').toField(), + from.toField(), + new Fr(amount), + nonce, + ]); + }; + const messageHash = await burnMessageHash(bridge.address, ownerAddress, withdrawAmount, nonce); + await wallets[0].signAndGetAuthWitness(messageHash); // wallet[0] -> ownerAddress + + // 5. Withdraw from L2 bridge + const withdrawTx = bridge.methods + .withdraw( + { address: token.address }, + { address: ethAccount.toField() }, + withdrawAmount, + { address: EthAddress.ZERO.toField() }, + nonce, + ) + .send(); + const withdrawReceipt = await withdrawTx.wait(); + expect(withdrawReceipt.status).toBe(TxStatus.MINED); + expect(await token.methods.balance_of_private({ address: ownerAddress }).view()).toBe( + afterPrivateBalance - withdrawAmount, + ); + + // TODO (#2291): Consume message on L1 -> update crosschain test harness to do this cleanly. + }, 120_000); +}); diff --git a/yarn-project/end-to-end/src/e2e_token_contract.test.ts b/yarn-project/end-to-end/src/e2e_token_contract.test.ts index 4e4643d295b7..b706585b124e 100644 --- a/yarn-project/end-to-end/src/e2e_token_contract.test.ts +++ b/yarn-project/end-to-end/src/e2e_token_contract.test.ts @@ -1,20 +1,7 @@ import { AztecNodeService } from '@aztec/aztec-node'; import { AztecRPCServer } from '@aztec/aztec-rpc'; -import { - Account, - AuthWitnessAccountContract, - AuthWitnessEntrypointWallet, - IAuthWitnessAccountEntrypoint, - computeMessageSecretHash, -} from '@aztec/aztec.js'; -import { - CircuitsWasm, - CompleteAddress, - Fr, - FunctionSelector, - GeneratorIndex, - GrumpkinScalar, -} from '@aztec/circuits.js'; +import { AuthWitnessEntrypointWallet, computeMessageSecretHash } from '@aztec/aztec.js'; +import { CircuitsWasm, CompleteAddress, Fr, FunctionSelector, GeneratorIndex } from '@aztec/circuits.js'; import { pedersenPlookupCompressWithHashIndex } from '@aztec/circuits.js/barretenberg'; import { DebugLogger } from '@aztec/foundation/log'; import { SchnorrAuthWitnessAccountContract, TokenContract } from '@aztec/noir-contracts/types'; @@ -22,7 +9,7 @@ import { AztecRPC, TxStatus } from '@aztec/types'; import { jest } from '@jest/globals'; -import { setup } from './fixtures/utils.js'; +import { createAuthWitnessAccounts, setup } from './fixtures/utils.js'; import { TokenSimulator } from './simulators/token_simulator.js'; const hashPayload = async (payload: Fr[]) => { @@ -50,29 +37,7 @@ describe('e2e_token_contract', () => { beforeAll(async () => { ({ aztecNode, aztecRpcServer, logger } = await setup(0)); - - { - const _accounts = []; - for (let i = 0; i < 3; i++) { - const privateKey = GrumpkinScalar.random(); - const account = new Account(aztecRpcServer, privateKey, new AuthWitnessAccountContract(privateKey)); - const deployTx = await account.deploy(); - await deployTx.wait({ interval: 0.1 }); - _accounts.push(account); - } - wallets = await Promise.all( - _accounts.map( - async account => - new AuthWitnessEntrypointWallet( - aztecRpcServer, - (await account.getEntrypoint()) as unknown as IAuthWitnessAccountEntrypoint, - await account.getCompleteAddress(), - ), - ), - ); - //wallet = new AuthWitnessEntrypointWallet(aztecRpcServer, await AuthEntrypointCollection.fromAccounts(_accounts)); - accounts = await wallets[0].getAccounts(); - } + ({ accounts, wallets } = await createAuthWitnessAccounts(aztecRpcServer, 3)); { logger(`Deploying token contract`); diff --git a/yarn-project/end-to-end/src/fixtures/utils.ts b/yarn-project/end-to-end/src/fixtures/utils.ts index e5333ff9b0dd..3b21f1d8db4d 100644 --- a/yarn-project/end-to-end/src/fixtures/utils.ts +++ b/yarn-project/end-to-end/src/fixtures/utils.ts @@ -1,11 +1,15 @@ import { AztecNodeConfig, AztecNodeService, getConfigEnvVars } from '@aztec/aztec-node'; import { RpcServerConfig, createAztecRPCServer, getConfigEnvVars as getRpcConfigEnvVars } from '@aztec/aztec-rpc'; import { + Account, AccountWallet, + AuthWitnessAccountContract, + AuthWitnessEntrypointWallet, AztecAddress, CheatCodes, EthAddress, EthCheatCodes, + IAuthWitnessAccountEntrypoint, Wallet, createAccounts, createAztecRpcClient as createJsonRpcClient, @@ -15,19 +19,19 @@ import { } from '@aztec/aztec.js'; import { CompleteAddress } from '@aztec/circuits.js'; import { DeployL1Contracts, deployL1Contract, deployL1Contracts } from '@aztec/ethereum'; -import { Fr } from '@aztec/foundation/fields'; +import { Fr, GrumpkinScalar } from '@aztec/foundation/fields'; import { DebugLogger, createDebugLogger } from '@aztec/foundation/log'; import { retryUntil } from '@aztec/foundation/retry'; import { PortalERC20Abi, PortalERC20Bytecode, TokenPortalAbi, TokenPortalBytecode } from '@aztec/l1-artifacts'; -import { NonNativeTokenContract } from '@aztec/noir-contracts/types'; +import { NonNativeTokenContract, TokenBridgeContract, TokenContract } from '@aztec/noir-contracts/types'; import { AztecRPC, L2BlockL2Logs, LogType, TxStatus } from '@aztec/types'; import { - Account, Chain, HDAccount, HttpTransport, PublicClient, + Account as ViemAccount, WalletClient, createPublicClient, createWalletClient, @@ -166,6 +170,47 @@ export async function setupAztecRPCServer( }; } +/** + * Creates AuthWitness Wallets and accounts. + * @param aztecRpcServer - The RPC server instance. + * @param numberOfAccounts - The number of new accounts to be created once the RPC server is initiated. + */ +export async function createAuthWitnessAccounts( + aztecRpcServer: AztecRPC, + numberOfAccounts = 1, +): Promise<{ + /** + * The accounts created by the RPC server. + */ + accounts: CompleteAddress[]; + /** + * The corresponding auth witness wallet instances. + */ + wallets: AuthWitnessEntrypointWallet[]; +}> { + const _accounts = []; + for (let i = 0; i < numberOfAccounts; i++) { + const privateKey = GrumpkinScalar.random(); + const account = new Account(aztecRpcServer, privateKey, new AuthWitnessAccountContract(privateKey)); + const deployTx = await account.deploy(); + await deployTx.wait({ interval: 0.1 }); + _accounts.push(account); + } + const wallets = await Promise.all( + _accounts.map( + async account => + new AuthWitnessEntrypointWallet( + aztecRpcServer, + (await account.getEntrypoint()) as unknown as IAuthWitnessAccountEntrypoint, + await account.getCompleteAddress(), + ), + ), + ); + //wallet = new AuthWitnessEntrypointWallet(aztecRpcServer, await AuthEntrypointCollection.fromAccounts(_accounts)); + const accounts = await wallets[0].getAccounts(); + return { accounts, wallets }; +} + /** * Sets up the environment for the end-to-end tests. * @param numberOfAccounts - The number of new accounts to be created once the RPC server is initiated. @@ -272,6 +317,99 @@ export function getLogger() { return createDebugLogger('aztec:' + describeBlockName); } +/** + * Deploy L1 token and portal, initialize portal, deploy a non native l2 token contract, its L2 bridge contract and attach is to the portal. + * @param wallet - the wallet instance + * @param walletClient - A viem WalletClient. + * @param publicClient - A viem PublicClient. + * @param rollupRegistryAddress - address of rollup registry to pass to initialize the token portal + * @param owner - owner of the L2 contract + * @param underlyingERC20Address - address of the underlying ERC20 contract to use (if none supplied, it deploys one) + * @returns l2 contract instance, bridge contract instance, token portal instance, token portal address and the underlying ERC20 instance + */ +export async function deployAndInitializeStandardizedTokenAndBridgeContracts( + wallet: Wallet, + walletClient: WalletClient, + publicClient: PublicClient, + rollupRegistryAddress: EthAddress, + owner: AztecAddress, + underlyingERC20Address?: EthAddress, +) { + // deploy underlying contract if no address supplied + if (!underlyingERC20Address) { + underlyingERC20Address = await deployL1Contract(walletClient, publicClient, PortalERC20Abi, PortalERC20Bytecode); + } + const underlyingERC20: any = getContract({ + address: underlyingERC20Address.toString(), + abi: PortalERC20Abi, + walletClient, + publicClient, + }); + + // deploy the token portal + const tokenPortalAddress = await deployL1Contract(walletClient, publicClient, TokenPortalAbi, TokenPortalBytecode); + const tokenPortal: any = getContract({ + address: tokenPortalAddress.toString(), + abi: TokenPortalAbi, + walletClient, + publicClient, + }); + + // deploy l2 token + const deployTx = TokenContract.deploy(wallet).send(); + + // deploy l2 token bridge and attach to the portal + const bridgeTx = TokenBridgeContract.deploy(wallet).send({ + portalContract: tokenPortalAddress, + contractAddressSalt: Fr.random(), + }); + + // now wait for the deploy txs to be mined. This way we send all tx in the same rollup. + const deployReceipt = await deployTx.wait(); + if (deployReceipt.status !== TxStatus.MINED) throw new Error(`Deploy token tx status is ${deployReceipt.status}`); + const l2Token = await TokenContract.at(deployReceipt.contractAddress!, wallet); + + const bridgeReceipt = await bridgeTx.wait(); + if (bridgeReceipt.status !== TxStatus.MINED) throw new Error(`Deploy bridge tx status is ${bridgeReceipt.status}`); + const bridge = await TokenBridgeContract.at(bridgeReceipt.contractAddress!, wallet); + await bridge.attach(tokenPortalAddress); + const bridgeAddress = bridge.address.toString() as `0x${string}`; + + // initialize l2 token + const initializeTx = l2Token.methods._initialize({ address: owner }).send(); + + // initialize bridge + const initializeBridgeTx = bridge.methods._initialize({ address: l2Token.address }).send(); + + // now we wait for the txs to be mined. This way we send all tx in the same rollup. + const initializeReceipt = await initializeTx.wait(); + if (initializeReceipt.status !== TxStatus.MINED) + throw new Error(`Initialize token tx status is ${initializeReceipt.status}`); + if ((await l2Token.methods.admin().view()) !== owner.toBigInt()) throw new Error(`Token admin is not ${owner}`); + + const initializeBridgeReceipt = await initializeBridgeTx.wait(); + if (initializeBridgeReceipt.status !== TxStatus.MINED) + throw new Error(`Initialize token bridge tx status is ${initializeBridgeReceipt.status}`); + if ((await bridge.methods.token().view()) !== l2Token.address.toBigInt()) + throw new Error(`Bridge token is not ${l2Token.address}`); + + // make the bridge a minter on the token: + const makeMinterTx = l2Token.methods.set_minter({ address: bridge.address }, true).send(); + const makeMinterReceipt = await makeMinterTx.wait(); + if (makeMinterReceipt.status !== TxStatus.MINED) + throw new Error(`Make bridge a minter tx status is ${makeMinterReceipt.status}`); + if ((await l2Token.methods.is_minter({ address: bridge.address }).view()) === 1n) + throw new Error(`Bridge is not a minter`); + + // initialize portal + await tokenPortal.write.initialize( + [rollupRegistryAddress.toString(), underlyingERC20Address.toString(), bridgeAddress], + {} as any, + ); + + return { l2Token, bridge, tokenPortalAddress, tokenPortal, underlyingERC20 }; +} + /** * Deploy L1 token and portal, initialize portal, deploy a non native l2 token contract and attach is to the portal. * @param aztecRpcServer - the aztec rpc server instance @@ -285,7 +423,7 @@ export function getLogger() { */ export async function deployAndInitializeNonNativeL2TokenContracts( wallet: Wallet, - walletClient: WalletClient, + walletClient: WalletClient, publicClient: PublicClient, rollupRegistryAddress: EthAddress, initialBalance = 0n, diff --git a/yarn-project/noir-contracts/Nargo.toml b/yarn-project/noir-contracts/Nargo.toml index 9f6306f3b9a9..bf8fca90ec68 100644 --- a/yarn-project/noir-contracts/Nargo.toml +++ b/yarn-project/noir-contracts/Nargo.toml @@ -24,5 +24,6 @@ members = [ "src/contracts/schnorr_single_key_account_contract", "src/contracts/test_contract", "src/contracts/token_contract", + "src/contracts/token_bridge_contract", "src/contracts/uniswap_contract", ] diff --git a/yarn-project/noir-contracts/src/contracts/token_bridge_contract/Nargo.toml b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/Nargo.toml new file mode 100644 index 000000000000..8e9c4ab3e70d --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/Nargo.toml @@ -0,0 +1,10 @@ +[package] +name = "token_bridge_contract" +authors = [""] +compiler_version = "0.1" +type = "contract" + +[dependencies] +aztec = { path = "../../../../aztec-nr/aztec" } +value_note = { path = "../../../../aztec-nr/value-note"} +safe_math = { path = "../../../../aztec-nr/safe-math" } diff --git a/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/main.nr b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/main.nr new file mode 100644 index 000000000000..0399415907df --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/main.nr @@ -0,0 +1,166 @@ +mod types; +mod util; +mod token_interface; + +// Minimal implementation of the token bridge that can move funds between L1 <> L2. +// The bridge has a corresponding Portal contract on L1 that it is attached to +// And corresponds to a Token on L2 that uses the `AuthWit` accounts pattern. +// Bridge has to be set as a minter on the token before it can be sued +contract TokenBridge { + // Libs + use dep::aztec::{ + context::{Context}, + state_vars::{public_state::PublicState}, + types::type_serialisation::field_serialisation::{ + FieldSerialisationMethods, FIELD_SERIALISED_LEN, + }, + oracle::compute_selector::compute_selector, + }; + + use crate::types::{AztecAddress, EthereumAddress}; + use crate::token_interface::Token; + use crate::util::{get_mint_content_hash, get_withdraw_content_hash, compute_secret_hash}; + + struct Storage { + token: PublicState, + } + + impl Storage { + fn init(context: Context) -> pub Self { + Storage { + token: PublicState::new( + context, + 1, + FieldSerialisationMethods, + ) + } + } + } + + #[aztec(private)] + fn constructor() { + // Currently not possible to execute public calls from constructor as code not yet available to sequencer. + // let selector = compute_selector("_initialize((Field))"); + // let _callStackItem = context.call_public_function(context.this_address(), selector, [token]); + } + + // Consumes a L1->L2 message and calls the token contract to mint the appropriate amount publicly + #[aztec(public)] + fn deposit_public( + amount: Field, + msg_key: Field, // L1 to L2 message key as derived from the inbox contract + secret: Field, + canceller: EthereumAddress, + ) -> Field { + let storage = Storage::init(Context::public(&mut context)); + + let content_hash = get_mint_content_hash(amount, context.msg_sender(), canceller.address); + + // Consume message and emit nullifier + context.consume_l1_to_l2_message(msg_key, content_hash, secret); + + // Mint token on L2 + // If I call using the interface, for some reason, the kernel queues mint_public twice and mints double the tokens. + // So, for now, call the token contract directly + // Token::at(storage.token.read()).mint_public(context, context.msg_sender(), amount) + context.call_public_function(storage.token.read(), compute_selector("mint_public((Field),Field)"), [context.msg_sender(), amount])[0] + } + + // Consumes a L1->L2 message and calls the token contract to mint the appropriate amount in private assets + // User needs to call token.redeem_shield() to get the private assets + // This method is public because it accesses public storage. For similar reasons, the corresponding call on the token is also public + #[aztec(public)] + fn deposit( + amount: Field, + msg_key: Field, // L1 to L2 message key as derived from the inbox contract + secret: Field, + canceller: EthereumAddress, + ) -> Field { + let storage = Storage::init(Context::public(&mut context)); + + let content_hash = get_mint_content_hash(amount, context.msg_sender(), canceller.address); + + // Consume message and emit nullifier + context.consume_l1_to_l2_message(msg_key, content_hash, secret); + + // Mint token on L2 + let secret_hash = compute_secret_hash(secret); + Token::at(storage.token.read()).mint_private(context, amount, secret_hash) + } + + // Burns the appropriate amount of tokens and creates a L2 to L1 withdraw message publicly + // Requires `from` to give approval to the bridge to burn tokens on their behalf using witness signatures + #[aztec(public)] + fn withdraw_public( + recipient: EthereumAddress, // ethereum address to withdraw to + amount: Field, + callerOnL1: EthereumAddress, // ethereum address that can call this function on the L1 portal (0x0 if anyone can call) + nonce: Field, // used in creating the approval message (to prevent replay attacks) + ) -> Field { + let storage = Storage::init(Context::public(&mut context)); + + // Burn tokens on L2 + let return_value = Token::at(storage.token.read()).burn_public(context, context.msg_sender(), amount, nonce); + + let content = get_withdraw_content_hash(amount, recipient.address, callerOnL1.address); + + // Emit the l2 to l1 message + context.message_portal(content); + + return_value + } + + // Burns the appropriate amount of tokens and creates a L2 to L1 withdraw message privately + // Requires `from` to give approval to the bridge to burn tokens on their behalf using witness signatures + #[aztec(private)] + fn withdraw( + token: AztecAddress, // can't read public storage in private, so pass the token and call an internal public fn to check if provided token is as expected. + recipient: EthereumAddress, // ethereum address to withdraw to + amount: Field, + callerOnL1: EthereumAddress, // ethereum address that can call this function on the L1 portal (0x0 if anyone can call) + nonce: Field, // used in creating the approval message (to prevent replay attacks) + ) -> Field { + // can't read public storage (`storage.token`) in private so let the user pass it in + // later assert that this token address is as expected + // let return_value = Token::at(token.address).burn(&mut context, context.msg_sender(), amount, nonce); + let return_value = context.call_private_function( + token.address, + compute_selector("burn((Field),Field,Field)"), + [context.msg_sender(), amount, nonce] + ); + let content = get_withdraw_content_hash(amount, recipient.address, callerOnL1.address); + // Emit the l2 to l1 message + context.message_portal(content); + + // Assert that user provided token address is same as seen in storage. + context.call_public_function(token.address, compute_selector("_assert_token_is_same(Field)"), [token.address]); + + return_value[0] + + } + + // /// Unconstrained /// + + unconstrained fn token() -> Field { + let storage = Storage::init(Context::none()); + storage.token.read() + } + + /// SHOULD BE Internal /// + + // We cannot do this from the constructor currently + // Since this should be internal, for now, we ignore the safety checks of it, as they are + // enforced by it being internal and only called from the constructor. + #[aztec(public)] + fn _initialize(token: AztecAddress) { + let storage = Storage::init(Context::public(&mut context)); + storage.token.write(token.address); + } + + #[aztec(public)] + internal fn _assert_token_is_same(token: Field) { + let storage = Storage::init(Context::public(&mut context)); + assert(storage.token.read() == token, "Token address is not the same as seen in storage"); + } + +} diff --git a/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/token_interface.nr b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/token_interface.nr new file mode 100644 index 000000000000..1646aeaf1c96 --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/token_interface.nr @@ -0,0 +1,74 @@ +use dep::aztec::{ + context::{ PrivateContext, PublicContext, Context }, + oracle::compute_selector::compute_selector +}; + +struct Token { + address: Field, +} + +impl Token { + fn at(address: Field) -> Self { + Self { + address, + } + } + + fn mint_public( + self, + context: PublicContext, + to: Field, + amount: Field, + ) -> Field { + let return_value = context.call_public_function( + self.address, + compute_selector("mint_public((Field),Field)"), + [to, amount] + ); + return_value[0] + } + + fn mint_private( + self, + context: PublicContext, + amount: Field, + secret_hash: Field, + ) -> Field { + let return_value = context.call_public_function( + self.address, + compute_selector("mint_private(Field,Field)"), + [amount, secret_hash] + ); + return_value[0] + } + + fn burn( + self, + context: &mut PrivateContext, + from: Field, + amount: Field, + nonce: Field, + ) -> Field { + let return_value = context.call_private_function( + self.address, + compute_selector("burn((Field),Field,Field)"), + [from, amount, nonce] + ); + return_value[0] + } + + fn burn_public( + self, + context: PublicContext, + from: Field, + amount: Field, + nonce: Field, + ) -> Field { + let return_value = context.call_public_function( + self.address, + compute_selector("burn_public((Field),Field,Field)"), + [from, amount, nonce] + ); + return_value[0] + } +} diff --git a/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/types.nr b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/types.nr new file mode 100644 index 000000000000..a941415ba080 --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/types.nr @@ -0,0 +1,43 @@ +struct AztecAddress { + address: Field +} + +impl AztecAddress { + fn new(address: Field) -> Self { + Self { + address + } + } + + fn serialize(self: Self) -> [Field; 1] { + [self.address] + } + + fn deserialize(fields: [Field; 1]) -> Self { + Self { + address: fields[0] + } + } +} + +struct EthereumAddress { + address: Field +} + +impl EthereumAddress { + fn new(address: Field) -> Self { + Self { + address + } + } + + fn serialize(self: Self) -> [Field; 1] { + [self.address] + } + + fn deserialize(fields: [Field; 1]) -> Self { + Self { + address: fields[0] + } + } +} diff --git a/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/util.nr b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/util.nr new file mode 100644 index 000000000000..9b6d5ed6deac --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/token_bridge_contract/src/util.nr @@ -0,0 +1,87 @@ +use dep::std::hash::{pedersen_with_separator, sha256}; +use dep::aztec::constants_gen::{ + GENERATOR_INDEX__SIGNATURE_PAYLOAD, + GENERATOR_INDEX__L1_TO_L2_MESSAGE_SECRET, +}; + +fn compute_secret_hash(secret: Field) -> Field { + // TODO(#1205) This is probably not the right index to use + pedersen_with_separator([secret], GENERATOR_INDEX__L1_TO_L2_MESSAGE_SECRET)[0] +} + +// Computes a content hash of a deposit/mint message. +fn get_mint_content_hash(amount: Field, owner_address: Field, canceller: Field) -> Field { + let mut hash_bytes: [u8; 100] = [0; 100]; + let amount_bytes = amount.to_be_bytes(32); + let recipient_bytes = owner_address.to_be_bytes(32); + let canceller_bytes = canceller.to_be_bytes(32); + + for i in 0..32 { + hash_bytes[i + 4] = amount_bytes[i]; + hash_bytes[i + 36] = recipient_bytes[i]; + hash_bytes[i + 68] = canceller_bytes[i]; + } + + // Function selector: 0xeeb73071 keccak256('mint(uint256,bytes32,address)') + hash_bytes[0] = 0xee; + hash_bytes[1] = 0xb7; + hash_bytes[2] = 0x30; + hash_bytes[3] = 0x71; + + let content_sha256 = sha256(hash_bytes); + + // // Convert the content_sha256 to a field element + let mut v = 1; + let mut high = 0 as Field; + let mut low = 0 as Field; + + for i in 0..16 { + high = high + (content_sha256[15 - i] as Field) * v; + low = low + (content_sha256[16 + 15 - i] as Field) * v; + v = v * 256; + } + + // Abuse that a % p + b % p = (a + b) % p and that low < p + let content_hash = low + high * v; + content_hash +} + +// Computes a content hash of a withdraw message. +fn get_withdraw_content_hash(amount: Field, recipient: Field, callerOnL1: Field) -> Field { + // Compute the content hash + // Compute sha256(selector || amount || recipient) + // then convert to a single field element + // add that to the l2 to l1 messages + let mut hash_bytes: [u8; 100] = [0; 100]; + let amount_bytes = amount.to_be_bytes(32); + let recipient_bytes = recipient.to_be_bytes(32); + let callerOnL1_bytes = callerOnL1.to_be_bytes(32); + + // 0xb460af94, selector for "withdraw(uint256,address,address)" + hash_bytes[0] = 0xb4; + hash_bytes[1] = 0x60; + hash_bytes[2] = 0xaf; + hash_bytes[3] = 0x94; + + for i in 0..32 { + hash_bytes[i + 4] = amount_bytes[i]; + hash_bytes[i + 36] = recipient_bytes[i]; + hash_bytes[i + 68] = callerOnL1_bytes[i]; + } + let content_sha256 = sha256(hash_bytes); + + // Convert the content_sha256 to a field element + let mut v = 1; + let mut high = 0 as Field; + let mut low = 0 as Field; + + for i in 0..16 { + high = high + (content_sha256[15 - i] as Field) * v; + low = low + (content_sha256[16 + 15 - i] as Field) * v; + v = v * 256; + } + + // Abuse that a % p + b % p = (a + b) % p and that low < p + let content = low + high * v; + content +}