diff --git a/.github/workflows/_build-contracts.yml b/.github/workflows/_build-contracts.yml index 2630fcd6..48bdf2c4 100644 --- a/.github/workflows/_build-contracts.yml +++ b/.github/workflows/_build-contracts.yml @@ -9,7 +9,6 @@ jobs: main: name: Generate, compile and lint contracts runs-on: [self-hosted, Linux, X64, large] - #runs-on: ubuntu-20.04 timeout-minutes: 10 steps: diff --git a/.github/workflows/_check-vars-and-secrets.yml b/.github/workflows/_check-vars-and-secrets.yml index 3f7050f4..676611d8 100644 --- a/.github/workflows/_check-vars-and-secrets.yml +++ b/.github/workflows/_check-vars-and-secrets.yml @@ -18,6 +18,7 @@ jobs: -z '${{ vars.CI_TESTNET_ALICE_PUBLIC_KEY }} }}' || \ -z '${{ vars.CI_TESTNET_BOB_PUBLIC_KEY }} }}' || \ -z '${{ vars.CI_TESTNET_CHARLIE_PUBLIC_KEY }} }}' || \ + -z '${{ vars.CI_TESTNET_TS_SDK_PUBLIC_KEY }} }}' || \ -z '${{ vars.CI_TESTNET_RELAYER_SIGNER_ADDRESSES }} }}' || \ -z '${{ vars.CI_TESTNET_FEE_DESTINATION }} }}' ]]; then @@ -45,6 +46,7 @@ jobs: -z '${{ secrets.CI_TESTNET_ALICE_PRIVATE_KEY }}' || \ -z '${{ secrets.CI_TESTNET_BOB_PRIVATE_KEY }}' || \ -z '${{ secrets.CI_TESTNET_CHARLIE_PRIVATE_KEY }}' || \ + -z '${{ secrets.CI_TESTNET_TS_SDK_PRIVATE_KEY }}' || \ -z '${{ secrets.CI_TESTNET_FEE_DESTINATION_KEY }}' || \ -z '${{ secrets.CI_TESTNET_RELAYER_SIGNING_KEYS }}' || \ -z '${{ secrets.NPM_PUBLISHING_KEY }}' diff --git a/.github/workflows/_ts-sdk-playwright-tests.yml b/.github/workflows/_ts-sdk-playwright-tests.yml index 61a5c628..66a3eabf 100644 --- a/.github/workflows/_ts-sdk-playwright-tests.yml +++ b/.github/workflows/_ts-sdk-playwright-tests.yml @@ -6,10 +6,10 @@ on: workflow_dispatch: jobs: - build-rust-binary: - name: Build Rust binary - runs-on: ubuntu-22.04 - timeout-minutes: 10 + build-rust-deps: + name: Build Rust dependencies + runs-on: [self-hosted, Linux, X64, large] + timeout-minutes: 20 env: RUSTC_WRAPPER: sccache steps: @@ -25,7 +25,7 @@ jobs: poseidon-gadget-private-key: ${{ secrets.SSH_PRIVATE_KEY }} zkos-circuits-private-key: ${{ secrets.ZKOS_CIRCUITS_SSH_PRIVATE_KEY }} - - name: Build Rust binary + - name: Build Rust-TS conversions binary run: cargo build --manifest-path crates/test-ts-conversions/Cargo.toml - name: Build relayer @@ -41,7 +41,7 @@ jobs: tags: shielder-relayer outputs: type=docker,dest=/tmp/shielder-relayer.tar - - name: Upload binary to artifacts + - name: Upload Rust-TS conversions binary to artifacts uses: actions/upload-artifact@v4 with: name: test-ts-conversions-binary @@ -57,9 +57,9 @@ jobs: ts-sdk-playwright-tests: name: Run shielder-sdk Playwright tests - runs-on: ubuntu-22.04 - needs: [build-rust-binary] - timeout-minutes: 10 + runs-on: [self-hosted, Linux, X64, large] + needs: [build-rust-deps] + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -70,13 +70,15 @@ jobs: shardTotal: [1] threads: [st, mt] steps: - - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - name: Checkout source code uses: actions/checkout@v4 + - name: Prepare Rust env + uses: ./.github/actions/prepare-rust-env + with: + poseidon-gadget-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + zkos-circuits-private-key: ${{ secrets.ZKOS_CIRCUITS_SSH_PRIVATE_KEY }} + - name: Cache pnpm modules uses: actions/cache@v4 with: @@ -98,10 +100,6 @@ jobs: contract-suite version: nightly-31dd1f77fd9156d09836486d97963cec7f555343 - - name: Run anvil node in background - shell: bash - run: make anvil & - - name: Install dependencies shell: bash run: make deps @@ -118,15 +116,6 @@ jobs: name: contract-artifacts path: artifacts - - name: Deploy eth contracts - shell: bash - run: | - NETWORK=anvil make deploy-contracts - SHIELDER_CONTRACT_ADDRESS=$( - NETWORK=anvil make deploy-contracts \ - | grep 'Shielder deployed at:' | awk '{print $NF}') - echo "SHIELDER_CONTRACT_ADDRESS=${SHIELDER_CONTRACT_ADDRESS}" >> $GITHUB_ENV - - name: Download generated wasm from artifacts uses: actions/download-artifact@v4 with: @@ -148,15 +137,6 @@ jobs: - name: Load relayer image run: docker load --input /tmp/shielder-relayer.tar - - name: Run shielder-relayer - run: | - source ../../tooling-e2e-tests/local_env.sh - DOCKER_USER="$(id -u):$(id -g)" \ - RELAYER_CONTAINER_NAME=shielder-relayer \ - RELAYER_DOCKER_IMAGE=shielder-relayer \ - ./run-relayer.sh& - working-directory: crates/shielder-relayer - - name: Executable permissions run: chmod +x target/debug/test-ts-conversions @@ -186,16 +166,10 @@ jobs: - name: Run tests (shielder-sdk-tests) run: | - if [[ ${{ matrix.threads }} == "st" ]]; then - CONFIG="playwright.singlethreaded.config.mjs" - else - CONFIG="playwright.multithreaded.config.mjs" - fi - source ../../tooling-e2e-tests/local_env.sh - RELAYER_SIGNER_ADDRESSES="$RELAYER_SIGNER_ADDRESSES" \ - pnpm playwright test --config $CONFIG \ - --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - working-directory: ts/shielder-sdk-tests + NO_FORMATTING=true \ + THREADING=${{ matrix.threads }} \ + PLAYWRIGHT_SHARDS=--shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} \ + ./tooling-e2e-tests/ts_sdk_tests.sh - name: Upload blob report to GitHub Actions Artifacts if: ${{ !cancelled() }} @@ -245,7 +219,7 @@ jobs: retention-days: 14 clean-rust-artifact: - name: Clean rust artifacts + name: Clean rust dependencies runs-on: ubuntu-22.04 if: ${{ always() }} needs: [ts-sdk-playwright-tests] diff --git a/.github/workflows/testnet-nightly-e2e.yml b/.github/workflows/testnet-nightly-e2e.yml index 3f4e9cec..63e9f597 100644 --- a/.github/workflows/testnet-nightly-e2e.yml +++ b/.github/workflows/testnet-nightly-e2e.yml @@ -3,7 +3,7 @@ name: Nightly E2E tests run on testnet on: schedule: - - cron: '00 23 * * *' + - cron: "00 23 * * *" workflow_dispatch: concurrency: @@ -85,6 +85,39 @@ jobs: shell: bash run: make compile-contracts + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Build shielder-wasm + run: cd crates/shielder-wasm && make all + + - name: Build Rust-TS conversions binary + run: cargo build --manifest-path crates/test-ts-conversions/Cargo.toml + + - uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Install dependencies (shielder-sdk) + run: pnpm install --frozen-lockfile + working-directory: ts/shielder-sdk + + - name: Build shielder-sdk + run: pnpm build + working-directory: ts/shielder-sdk + + - name: Install dependencies (shielder-sdk-tests) + run: pnpm install --frozen-lockfile + working-directory: ts/shielder-sdk-tests + + - name: Install Playwright dependencies (shielder-sdk-tests) + run: pnpm postinstall + working-directory: ts/shielder-sdk-tests + + - name: Build shielder-sdk-tests + run: pnpm build + working-directory: ts/shielder-sdk-tests + - name: Run e2e tooling tests env: DEPLOYER_PRIVATE_KEY: ${{ secrets.CI_TESTNET_DEPLOYER_PRIVATE_KEY }} @@ -94,6 +127,8 @@ jobs: BOB_PRIVATE_KEY: ${{ secrets.CI_TESTNET_BOB_PRIVATE_KEY }} CHARLIE_PUBLIC_KEY: ${{ vars.CI_TESTNET_CHARLIE_PUBLIC_KEY }} CHARLIE_PRIVATE_KEY: ${{ secrets.CI_TESTNET_CHARLIE_PRIVATE_KEY }} + TS_SDK_PUBLIC_KEY: ${{ vars.CI_TESTNET_TS_SDK_PUBLIC_KEY }} + TS_SDK_PRIVATE_KEY: ${{ secrets.CI_TESTNET_TS_SDK_PRIVATE_KEY }} FEE_DESTINATION: ${{ vars.CI_TESTNET_FEE_DESTINATION }} FEE_DESTINATION_KEY: ${{ secrets.CI_TESTNET_FEE_DESTINATION_KEY }} RELAYER_SIGNER_ADDRESSES: ${{ vars.CI_TESTNET_RELAYER_SIGNER_ADDRESSES }} @@ -102,6 +137,7 @@ jobs: NO_FORMATTING=true TESTNET=true ./tooling-e2e-tests/full_scenario.sh NO_FORMATTING=true TESTNET=true ./tooling-e2e-tests/recovery_scenario.sh NO_FORMATTING=true TESTNET=true ./tooling-e2e-tests/many_actors.sh + NO_FORMATTING=true TESTNET=true ./tooling-e2e-tests/ts_sdk_tests.sh slack-notification: name: Slack notification diff --git a/contracts/Shielder.sol b/contracts/Shielder.sol index e4d4941c..88d5ec30 100644 --- a/contracts/Shielder.sol +++ b/contracts/Shielder.sol @@ -78,18 +78,21 @@ contract Shielder is // -- Events -- event NewAccountNative( + bytes3 contractVersion, uint256 idHash, uint256 amount, uint256 newNote, uint256 newNoteIndex ); event DepositNative( + bytes3 contractVersion, uint256 idHiding, uint256 amount, uint256 newNote, uint256 newNoteIndex ); event WithdrawNative( + bytes3 contractVersion, uint256 idHiding, uint256 amount, address to, @@ -114,12 +117,19 @@ contract Shielder is error ContractBalanceLimitReached(); error LeafIsNotInTheTree(); error PrecompileCallFailed(); + error WrongContractVersion(bytes3 actual, bytes3 expectedByCaller); modifier withinDepositLimit() { if (msg.value > depositLimit) revert AmountOverDepositLimit(); _; } + modifier restrictContractVersion(bytes3 expectedByCaller) { + if (expectedByCaller != CONTRACT_VERSION) + revert WrongContractVersion(CONTRACT_VERSION, expectedByCaller); + _; + } + constructor() { _disableInitializers(); } @@ -173,10 +183,17 @@ contract Shielder is * This transaction serves as the entrypoint to the Shielder. */ function newAccountNative( + bytes3 expectedContractVersion, uint256 newNote, uint256 idHash, bytes calldata proof - ) external payable whenNotPaused withinDepositLimit { + ) + external + payable + whenNotPaused + withinDepositLimit + restrictContractVersion(expectedContractVersion) + { uint256 amount = msg.value; if (nullifiers[idHash] != 0) revert DuplicatedNullifier(); // `address(this).balance` already includes `msg.value`. @@ -202,19 +219,26 @@ contract Shielder is merkleRoots.add(merkleTree.root); registerNullifier(idHash); - emit NewAccountNative(idHash, amount, newNote, index); + emit NewAccountNative(CONTRACT_VERSION, idHash, amount, newNote, index); } /* * Make a native token deposit into the Shielder */ function depositNative( + bytes3 expectedContractVersion, uint256 idHiding, uint256 oldNullifierHash, uint256 newNote, uint256 merkleRoot, bytes calldata proof - ) external payable whenNotPaused withinDepositLimit { + ) + external + payable + whenNotPaused + withinDepositLimit + restrictContractVersion(expectedContractVersion) + { uint256 amount = msg.value; if (amount == 0) revert ZeroAmount(); if (nullifiers[oldNullifierHash] != 0) revert DuplicatedNullifier(); @@ -244,13 +268,20 @@ contract Shielder is merkleRoots.add(merkleTree.root); registerNullifier(oldNullifierHash); - emit DepositNative(idHiding, msg.value, newNote, index); + emit DepositNative( + CONTRACT_VERSION, + idHiding, + msg.value, + newNote, + index + ); } /* * Withdraw shielded native funds */ function withdrawNative( + bytes3 expectedContractVersion, uint256 idHiding, uint256 amount, address withdrawAddress, @@ -260,7 +291,7 @@ contract Shielder is bytes calldata proof, address relayerAddress, uint256 relayerFee - ) external whenNotPaused { + ) external whenNotPaused restrictContractVersion(expectedContractVersion) { if (amount == 0) revert ZeroAmount(); if (amount <= relayerFee) revert FeeHigherThanAmount(); if (amount > MAX_TRANSACTION_AMOUNT) revert AmountTooHigh(); @@ -313,6 +344,7 @@ contract Shielder is if (!nativeTransferSuccess) revert NativeTransferFailed(); emit WithdrawNative( + CONTRACT_VERSION, idHiding, amount, withdrawAddress, diff --git a/crates/integration-tests/src/shielder/calls/deposit_native.rs b/crates/integration-tests/src/shielder/calls/deposit_native.rs index 53cb61bd..c0fa1681 100644 --- a/crates/integration-tests/src/shielder/calls/deposit_native.rs +++ b/crates/integration-tests/src/shielder/calls/deposit_native.rs @@ -1,6 +1,6 @@ use std::assert_matches::assert_matches; -use alloy_primitives::{Bytes, TxHash, U256}; +use alloy_primitives::{Bytes, FixedBytes, TxHash, U256}; use rstest::rstest; use shielder_rust_sdk::{ account::{ @@ -9,6 +9,7 @@ use shielder_rust_sdk::{ }, contract::ShielderContract::{ depositNativeCall, DepositNative, ShielderContractErrors, ShielderContractEvents, + WrongContractVersion, }, }; @@ -58,11 +59,13 @@ pub fn invoke_call( let call_result = invoke_shielder_call(deployment, calldata, Some(amount)); match call_result { - Ok(event) => { + Ok(events) => { + assert!(events.len() == 1); + let event = events[0].clone(); shielder_account.register_action((TxHash::default(), event.clone())); - Ok(event) + Ok(events) } - Err(_) => call_result, + Err(err) => Err(err), } } @@ -78,16 +81,42 @@ fn succeeds(mut deployment: Deployment) { assert_eq!( result, - Ok(ShielderContractEvents::DepositNative(DepositNative { + Ok(vec![ShielderContractEvents::DepositNative(DepositNative { + contractVersion: FixedBytes([0, 0, 1]), idHiding: calldata.idHiding, amount: U256::from(amount), newNote: calldata.newNote, newNoteIndex: note_index.saturating_add(U256::from(1)), - })) + })]) ); assert!(actor_balance_decreased_by(&deployment, U256::from(15))); assert_eq!(shielder_account.shielded_amount, U256::from(15)) } +#[rstest] +fn fails_if_incorrect_expected_version(mut deployment: Deployment) { + let mut shielder_account = + new_account_native::create_account_and_call(&mut deployment, U256::from(1), U256::from(10)) + .unwrap(); + let (mut calldata, _) = prepare_call(&mut deployment, &mut shielder_account, U256::ZERO); + calldata.expectedContractVersion = FixedBytes([9, 8, 7]); + let result = invoke_call( + &mut deployment, + &mut shielder_account, + U256::from(5), + &calldata, + ); + + assert_matches!( + result, + Err(ShielderContractErrors::WrongContractVersion( + WrongContractVersion { + actual: FixedBytes([0, 0, 1]), + expectedByCaller: FixedBytes([9, 8, 7]) + } + )) + ); + assert!(actor_balance_decreased_by(&deployment, U256::from(10))) +} #[rstest] fn can_consume_entire_contract_balance_limit(mut deployment: Deployment) { @@ -175,6 +204,7 @@ fn fails_if_merkle_root_does_not_exist(mut deployment: Deployment) { let mut shielder_account = ShielderAccount::default(); let calldata = depositNativeCall { + expectedContractVersion: FixedBytes([0, 0, 1]), idHiding: U256::ZERO, oldNullifierHash: U256::ZERO, newNote: U256::ZERO, diff --git a/crates/integration-tests/src/shielder/calls/new_account_native.rs b/crates/integration-tests/src/shielder/calls/new_account_native.rs index d718e431..b19bbdfb 100644 --- a/crates/integration-tests/src/shielder/calls/new_account_native.rs +++ b/crates/integration-tests/src/shielder/calls/new_account_native.rs @@ -1,11 +1,12 @@ use std::assert_matches::assert_matches; -use alloy_primitives::{TxHash, U256}; +use alloy_primitives::{FixedBytes, TxHash, U256}; use rstest::rstest; use shielder_rust_sdk::{ account::{call_data::NewAccountCallType, ShielderAccount}, contract::ShielderContract::{ newAccountNativeCall, NewAccountNative, ShielderContractErrors, ShielderContractEvents, + WrongContractVersion, }, }; @@ -33,11 +34,13 @@ pub fn invoke_call( let call_result = invoke_shielder_call(deployment, calldata, Some(amount)); match call_result { - Ok(event) => { + Ok(events) => { + assert!(events.len() == 1); + let event = events[0].clone(); shielder_account.register_action((TxHash::default(), event.clone())); - Ok(event) + Ok(events) } - Err(_) => call_result, + Err(err) => Err(err), } } @@ -67,17 +70,41 @@ fn succeeds(mut deployment: Deployment) { assert_eq!( result, - Ok(ShielderContractEvents::NewAccountNative(NewAccountNative { - idHash: calldata.idHash, - amount, - newNote: calldata.newNote, - newNoteIndex: U256::ZERO, - })) + Ok(vec![ShielderContractEvents::NewAccountNative( + NewAccountNative { + contractVersion: FixedBytes([0, 0, 1]), + idHash: calldata.idHash, + amount, + newNote: calldata.newNote, + newNoteIndex: U256::ZERO, + } + )]) ); assert!(actor_balance_decreased_by(&deployment, amount)); assert_eq!(shielder_account.shielded_amount, U256::from(amount)) } +#[rstest] +fn fails_if_incorrect_expected_version(mut deployment: Deployment) { + let mut shielder_account = ShielderAccount::default(); + let amount = U256::from(10); + let mut calldata = prepare_call(&mut deployment, &mut shielder_account, amount); + calldata.expectedContractVersion = FixedBytes([9, 8, 7]); + + let result = invoke_call(&mut deployment, &mut shielder_account, amount, &calldata); + + assert_matches!( + result, + Err(ShielderContractErrors::WrongContractVersion( + WrongContractVersion { + actual: FixedBytes([0, 0, 1]), + expectedByCaller: FixedBytes([9, 8, 7]), + } + )) + ); + assert!(actor_balance_decreased_by(&deployment, U256::ZERO)) +} + #[rstest] fn cannot_use_same_id_twice(mut deployment: Deployment) { assert!(create_account_and_call(&mut deployment, U256::from(1), U256::from(10)).is_ok()); @@ -97,7 +124,9 @@ fn can_consume_entire_contract_balance_limit(mut deployment: Deployment) { let result = invoke_call(&mut deployment, &mut shielder_account, amount, &calldata); assert!(result.is_ok()); - assert_matches!(result.unwrap(), ShielderContractEvents::NewAccountNative(_)); + let events = result.unwrap(); + assert!(events.len() == 1); + assert_matches!(events[0], ShielderContractEvents::NewAccountNative(_)); assert!(actor_balance_decreased_by(&deployment, amount)) } diff --git a/crates/integration-tests/src/shielder/calls/withdraw_native.rs b/crates/integration-tests/src/shielder/calls/withdraw_native.rs index 713c34a5..e92c78b4 100644 --- a/crates/integration-tests/src/shielder/calls/withdraw_native.rs +++ b/crates/integration-tests/src/shielder/calls/withdraw_native.rs @@ -1,6 +1,6 @@ use std::{assert_matches::assert_matches, str::FromStr}; -use alloy_primitives::{Address, Bytes, TxHash, U256}; +use alloy_primitives::{Address, Bytes, FixedBytes, TxHash, U256}; use rstest::rstest; use shielder_rust_sdk::{ account::{ @@ -9,6 +9,7 @@ use shielder_rust_sdk::{ }, contract::ShielderContract::{ withdrawNativeCall, ShielderContractErrors, ShielderContractEvents, WithdrawNative, + WrongContractVersion, }, version::ContractVersion, }; @@ -85,11 +86,13 @@ fn invoke_call( let call_result = invoke_shielder_call(deployment, calldata, None); match call_result { - Ok(event) => { + Ok(events) => { + assert!(events.len() == 1); + let event = events[0].clone(); shielder_account.register_action((TxHash::default(), event.clone())); - Ok(event) + Ok(events) } - Err(_) => call_result, + Err(err) => Err(err), } } @@ -108,15 +111,18 @@ fn succeeds(mut deployment: Deployment) { assert_eq!( withdraw_result, - Ok(ShielderContractEvents::WithdrawNative(WithdrawNative { - idHiding: withdraw_calldata.idHiding, - amount: U256::from(5), - withdrawAddress: Address::from_str(RECIPIENT_ADDRESS).unwrap(), - newNote: withdraw_calldata.newNote, - relayerAddress: Address::from_str(RELAYER_ADDRESS).unwrap(), - newNoteIndex: withdraw_note_index.saturating_add(U256::from(1)), - fee: U256::from(1), - })) + Ok(vec![ShielderContractEvents::WithdrawNative( + WithdrawNative { + contractVersion: FixedBytes([0, 0, 1]), + idHiding: withdraw_calldata.idHiding, + amount: U256::from(5), + withdrawAddress: Address::from_str(RECIPIENT_ADDRESS).unwrap(), + newNote: withdraw_calldata.newNote, + relayerAddress: Address::from_str(RELAYER_ADDRESS).unwrap(), + newNoteIndex: withdraw_note_index.saturating_add(U256::from(1)), + fee: U256::from(1), + } + )]) ); assert!(actor_balance_decreased_by(&deployment, U256::from(20))); assert!(recipient_balance_increased_by(&deployment, U256::from(4))); @@ -150,15 +156,18 @@ fn succeeds_after_deposit(mut deployment: Deployment) { assert_eq!( withdraw_result, - Ok(ShielderContractEvents::WithdrawNative(WithdrawNative { - idHiding: withdraw_calldata.idHiding, - amount: U256::from(5), - withdrawAddress: Address::from_str(RECIPIENT_ADDRESS).unwrap(), - newNote: withdraw_calldata.newNote, - relayerAddress: Address::from_str(RELAYER_ADDRESS).unwrap(), - newNoteIndex: withdraw_note_index.saturating_add(U256::from(1)), - fee: U256::from(1), - })) + Ok(vec![ShielderContractEvents::WithdrawNative( + WithdrawNative { + contractVersion: FixedBytes([0, 0, 1]), + idHiding: withdraw_calldata.idHiding, + amount: U256::from(5), + withdrawAddress: Address::from_str(RECIPIENT_ADDRESS).unwrap(), + newNote: withdraw_calldata.newNote, + relayerAddress: Address::from_str(RELAYER_ADDRESS).unwrap(), + newNoteIndex: withdraw_note_index.saturating_add(U256::from(1)), + fee: U256::from(1), + } + )]) ); assert!(actor_balance_decreased_by(&deployment, U256::from(30))); assert!(recipient_balance_increased_by(&deployment, U256::from(4))); @@ -272,11 +281,43 @@ fn rejects_too_high_amount(mut deployment: Deployment) { assert!(destination_balances_unchanged(&deployment)) } +#[rstest] +fn fails_if_incorrect_expected_version(mut deployment: Deployment) { + let mut shielder_account = ShielderAccount::default(); + + let calldata = withdrawNativeCall { + expectedContractVersion: FixedBytes([9, 8, 7]), + idHiding: U256::ZERO, + withdrawAddress: Address::from_str(RECIPIENT_ADDRESS).unwrap(), + relayerAddress: Address::from_str(RELAYER_ADDRESS).unwrap(), + relayerFee: U256::ZERO, + amount: U256::from(10), + merkleRoot: U256::ZERO, + oldNullifierHash: U256::ZERO, + newNote: U256::ZERO, + proof: Bytes::from(vec![]), + }; + let result = invoke_call(&mut deployment, &mut shielder_account, &calldata); + + assert_matches!( + result, + Err(ShielderContractErrors::WrongContractVersion( + WrongContractVersion { + actual: FixedBytes([0, 0, 1]), + expectedByCaller: FixedBytes([9, 8, 7]), + } + )) + ); + assert!(actor_balance_decreased_by(&deployment, U256::from(0))); + assert!(destination_balances_unchanged(&deployment)) +} + #[rstest] fn fails_if_merkle_root_does_not_exist(mut deployment: Deployment) { let mut shielder_account = ShielderAccount::default(); let calldata = withdrawNativeCall { + expectedContractVersion: FixedBytes([0, 0, 1]), idHiding: U256::ZERO, withdrawAddress: Address::from_str(RECIPIENT_ADDRESS).unwrap(), relayerAddress: Address::from_str(RELAYER_ADDRESS).unwrap(), diff --git a/crates/integration-tests/src/shielder/merkle.rs b/crates/integration-tests/src/shielder/merkle.rs index ad87513d..efaefb63 100644 --- a/crates/integration-tests/src/shielder/merkle.rs +++ b/crates/integration-tests/src/shielder/merkle.rs @@ -1,9 +1,18 @@ +use std::assert_matches::assert_matches; + use alloy_primitives::{Address, U256}; use alloy_sol_types::{SolCall, SolValue}; use evm_utils::EvmRunner; +use rstest::rstest; use shielder_circuits::consts::merkle_constants::{ARITY, NOTE_TREE_HEIGHT}; use shielder_rust_sdk::contract::ShielderContract::getMerklePathCall; +use crate::shielder::{ + calls::new_account_native, + deploy::{deployment, Deployment}, + invoke_shielder_call, +}; + pub fn get_merkle_args( shielder_address: Address, note_index: U256, @@ -34,3 +43,19 @@ fn reorganize_merkle_path(merkle_path: Vec) -> (U256, [[U256; ARITY]; NOTE (root, result) } + +#[rstest] +fn succeeds(mut deployment: Deployment) { + assert!(new_account_native::create_account_and_call( + &mut deployment, + U256::from(1), + U256::from(10) + ) + .is_ok()); + + let calldata = getMerklePathCall { id: U256::ZERO }; + let result = invoke_shielder_call(&mut deployment, &calldata, None); + + assert_matches!(result, Ok(_)); + assert!(result.unwrap().is_empty()) +} diff --git a/crates/integration-tests/src/shielder/mod.rs b/crates/integration-tests/src/shielder/mod.rs index 96b69da8..465d5a73 100644 --- a/crates/integration-tests/src/shielder/mod.rs +++ b/crates/integration-tests/src/shielder/mod.rs @@ -28,7 +28,7 @@ fn unpause_shielder(shielder: Address, evm: &mut EvmRunner) { .expect("Call failed"); } -type CallResult = Result; +type CallResult = Result, ShielderContractErrors>; fn invoke_shielder_call( deployment: &mut Deployment, @@ -51,11 +51,16 @@ fn invoke_shielder_call( })? .logs; - assert_eq!(logs.len(), 1); - let event = ShielderContractEvents::decode_log(&logs[0], true).expect("Decoding event failed"); - assert_eq!(event.address, deployment.contract_suite.shielder); - - Ok(event.data) + let events: Vec<_> = logs + .iter() + .map(|log| { + let event = + ShielderContractEvents::decode_log(log, true).expect("Decoding event failed"); + assert_eq!(event.address, deployment.contract_suite.shielder); + event.data + }) + .collect(); + Ok(events) } fn get_balance(deployment: &Deployment, address: &str) -> U256 { diff --git a/crates/shielder-cli/src/recovery.rs b/crates/shielder-cli/src/recovery.rs index bc01409f..74cabda6 100644 --- a/crates/shielder-cli/src/recovery.rs +++ b/crates/shielder-cli/src/recovery.rs @@ -87,10 +87,11 @@ async fn find_shielder_transaction( .expect("We should get full transactions"); for tx in txs { - let Some(event) = try_get_shielder_event_for_tx(provider, tx, block.header.hash).await? - else { - continue; + let event = match try_get_shielder_event_for_tx(provider, tx, block.header.hash).await? { + Some(event) => event, + _ => continue, }; + event.check_version().map_err(|_| anyhow!("Bad version"))?; let event_note = event.note(); let action = ShielderAction::from((tx.hash, event)); diff --git a/crates/shielder-cli/src/shielder_ops/withdraw.rs b/crates/shielder-cli/src/shielder_ops/withdraw.rs index e230dc56..6e1fa05d 100644 --- a/crates/shielder-cli/src/shielder_ops/withdraw.rs +++ b/crates/shielder-cli/src/shielder_ops/withdraw.rs @@ -103,6 +103,7 @@ async fn prepare_relayer_query( ); Ok(RelayQuery { + expected_contract_version: contract_version().to_bytes(), id_hiding: calldata.idHiding, amount, withdraw_address: to, diff --git a/crates/shielder-relayer/README.md b/crates/shielder-relayer/README.md index 16c28ca3..527467f5 100644 --- a/crates/shielder-relayer/README.md +++ b/crates/shielder-relayer/README.md @@ -74,6 +74,7 @@ Submits a new withdrawal transaction to the Shielder contract through the relaye It expects one json object in the body, compliant with the structure: ```rust pub struct RelayQuery { + pub expected_contract_version: FixedBytes<3>, pub amount: U256, pub withdraw_address: Address, pub merkle_root: U256, diff --git a/crates/shielder-relayer/run-relayer.sh b/crates/shielder-relayer/run-relayer.sh index af1d160c..a8faabbc 100755 --- a/crates/shielder-relayer/run-relayer.sh +++ b/crates/shielder-relayer/run-relayer.sh @@ -7,6 +7,7 @@ set -u # The following environment variables are required to run the Relayer service. Other configuration parameters # have their default fallback values. REQUIRED_RUN_VARS=( + "NODE_RPC_PORT" "NODE_RPC_URL" "FEE_DESTINATION_KEY" "RELAYER_SIGNING_KEYS" @@ -42,7 +43,11 @@ if [[ -n "${RELAYER_PORT:-}" ]]; then ARGS+=(-e RELAYER_PORT=${RELAYER_PORT}) fi if [[ -n "${NODE_RPC_URL:-}" ]]; then - ARGS+=(-e NODE_RPC_URL=${NODE_RPC_URL}) + if [[ "$OSTYPE" == "darwin"* ]]; then + ARGS+=(-e NODE_RPC_URL=http://host.docker.internal:${NODE_RPC_PORT}) + else + ARGS+=(-e NODE_RPC_URL=${NODE_RPC_URL}) + fi fi if [[ -n "${FEE_DESTINATION_KEY:-}" ]]; then ARGS+=(-e FEE_DESTINATION_KEY=${FEE_DESTINATION_KEY}) diff --git a/crates/shielder-relayer/src/lib.rs b/crates/shielder-relayer/src/lib.rs index f86ccff4..bf12d6dd 100644 --- a/crates/shielder-relayer/src/lib.rs +++ b/crates/shielder-relayer/src/lib.rs @@ -1,7 +1,7 @@ use axum::Json; use serde::{Deserialize, Serialize}; use shielder_rust_sdk::{ - alloy_primitives::{Address, Bytes, TxHash, U256}, + alloy_primitives::{Address, Bytes, FixedBytes, TxHash, U256}, native_token::ONE_TZERO, }; @@ -45,6 +45,7 @@ impl RelayResponse { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct RelayQuery { + pub expected_contract_version: FixedBytes<3>, pub id_hiding: U256, pub amount: U256, pub withdraw_address: Address, diff --git a/crates/shielder-relayer/src/relay/mod.rs b/crates/shielder-relayer/src/relay/mod.rs index 98a46bb7..987adfd1 100644 --- a/crates/shielder-relayer/src/relay/mod.rs +++ b/crates/shielder-relayer/src/relay/mod.rs @@ -6,7 +6,9 @@ use axum::{ }; use shielder_relayer::{relayer_fee, RelayQuery, RelayResponse, SimpleServiceResponse}; use shielder_rust_sdk::{ - alloy_primitives::Address, contract::ShielderContract::withdrawNativeCall, + alloy_primitives::Address, + contract::ShielderContract::withdrawNativeCall, + version::{contract_version, ContractVersion}, }; use tracing::{debug, error}; @@ -26,7 +28,11 @@ const OPTIMISTIC_DRY_RUN_THRESHOLD: u32 = 32; pub async fn relay(app_state: State, Json(query): Json) -> impl IntoResponse { debug!("Relay request received: {query:?}"); - let request_trace = RequestTrace::new(&query); + let mut request_trace = RequestTrace::new(&query); + + if let Err(response) = check_expected_version(&query, &mut request_trace) { + return response; + } let withdraw_call = create_call(query, app_state.fee_destination); let Ok(rx) = app_state @@ -72,6 +78,7 @@ fn server_error(msg: &str) -> Response { fn create_call(q: RelayQuery, relayer_address: Address) -> withdrawNativeCall { withdrawNativeCall { + expectedContractVersion: q.expected_contract_version, idHiding: q.id_hiding, withdrawAddress: q.withdraw_address, relayerAddress: relayer_address, @@ -83,3 +90,21 @@ fn create_call(q: RelayQuery, relayer_address: Address) -> withdrawNativeCall { proof: q.proof, } } + +fn check_expected_version( + query: &RelayQuery, + request_trace: &mut RequestTrace, +) -> Result<(), Response> { + let expected_by_client = ContractVersion::from_bytes(query.expected_contract_version); + let expected_by_relayer = contract_version(); + + if expected_by_client != expected_by_relayer { + request_trace.record_version_mismatch(expected_by_relayer, expected_by_client); + return Err(bad_request(&format!( + "Version mismatch: relayer expects {}, client expects {}", + expected_by_relayer.to_bytes(), + expected_by_client.to_bytes() + ))); + } + Ok(()) +} diff --git a/crates/shielder-relayer/src/relay/request_trace.rs b/crates/shielder-relayer/src/relay/request_trace.rs index d0e82dde..fd37e240 100644 --- a/crates/shielder-relayer/src/relay/request_trace.rs +++ b/crates/shielder-relayer/src/relay/request_trace.rs @@ -4,6 +4,7 @@ use shielder_relayer::RelayQuery; use shielder_rust_sdk::{ alloy_primitives::{Address, TxHash, U256}, contract::ShielderContractError, + version::ContractVersion, }; use tracing::{error, info}; @@ -58,6 +59,20 @@ impl RequestTrace { self.finish("✅ SUCCESS"); } + pub fn record_version_mismatch( + &mut self, + expected_by_relayer: ContractVersion, + expected_by_client: ContractVersion, + ) { + metrics::counter!(WITHDRAW_FAILURE).increment(1); + error!( + "Relay version mismatch: relayer expects {}, client expects {}", + expected_by_relayer.to_bytes(), + expected_by_client.to_bytes() + ); + self.finish("❌ VERSION FAILURE"); + } + pub fn record_failure(&mut self, err: ShielderContractError) { metrics::counter!(WITHDRAW_FAILURE).increment(1); error!("Relay failed: {err}"); diff --git a/crates/shielder-relayer/test-resources/AcceptingShielder.sol b/crates/shielder-relayer/test-resources/AcceptingShielder.sol index c6e32bd9..88e677a6 100644 --- a/crates/shielder-relayer/test-resources/AcceptingShielder.sol +++ b/crates/shielder-relayer/test-resources/AcceptingShielder.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; contract AcceptingShielder { function withdrawNative( + bytes3 expectedContractVersion, uint256 idHiding, uint256 amount, address withdrawAddress, diff --git a/crates/shielder-relayer/test-resources/RevertingShielder.sol b/crates/shielder-relayer/test-resources/RevertingShielder.sol index fc696ae4..029e9fb2 100644 --- a/crates/shielder-relayer/test-resources/RevertingShielder.sol +++ b/crates/shielder-relayer/test-resources/RevertingShielder.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; contract RevertingShielder { function withdrawNative( + bytes3 expectedContractVersion, uint256 idHiding, uint256 amount, address withdrawAddress, diff --git a/crates/shielder-relayer/tests/utils/mod.rs b/crates/shielder-relayer/tests/utils/mod.rs index 71f5f4f5..37f6a2a9 100644 --- a/crates/shielder-relayer/tests/utils/mod.rs +++ b/crates/shielder-relayer/tests/utils/mod.rs @@ -6,7 +6,10 @@ use rand::Rng; use reqwest::Response; use serde::{Deserialize, Serialize}; use shielder_relayer::RelayQuery; -use shielder_rust_sdk::alloy_primitives::{Address, Bytes, U256}; +use shielder_rust_sdk::{ + alloy_primitives::{Address, Bytes, U256}, + version::contract_version, +}; use testcontainers::{ core::IntoContainerPort, runners::AsyncRunner, ContainerAsync, ContainerRequest, Image, ImageExt, TestcontainersError, @@ -63,6 +66,7 @@ impl TestContext { reqwest::Client::new() .post(format!("{BASE_URL}:{}/relay", self.relayer_port)) .json(&RelayQuery { + expected_contract_version: contract_version().to_bytes(), id_hiding: U256::ZERO, amount: U256::from(1), withdraw_address: Address::from_str(FEE_DESTINATION).unwrap(), diff --git a/crates/shielder-rust-sdk/src/account/call_data.rs b/crates/shielder-rust-sdk/src/account/call_data.rs index 28fbf22f..91118a10 100644 --- a/crates/shielder-rust-sdk/src/account/call_data.rs +++ b/crates/shielder-rust-sdk/src/account/call_data.rs @@ -20,7 +20,7 @@ use crate::{ WithdrawCommitment, }, conversion::{field_to_u256, u256_to_field}, - version::ContractVersion, + version::{contract_version, ContractVersion}, }; struct ActionSecrets { @@ -82,6 +82,7 @@ impl CallType for NewAccountCallType { ) -> Self::Calldata { use shielder_circuits::circuits::new_account::NewAccountInstance::*; newAccountNativeCall { + expectedContractVersion: contract_version().to_bytes(), newNote: field_to_u256(prover_knowledge.compute_public_input(HashedNote)), idHash: field_to_u256(prover_knowledge.compute_public_input(HashedId)), proof: Bytes::from(proof), @@ -136,6 +137,7 @@ impl CallType for DepositCallType { ) -> Self::Calldata { use shielder_circuits::circuits::deposit::DepositInstance::*; depositNativeCall { + expectedContractVersion: contract_version().to_bytes(), idHiding: field_to_u256(pk.compute_public_input(IdHiding)), oldNullifierHash: field_to_u256(pk.compute_public_input(HashedOldNullifier)), newNote: field_to_u256(pk.compute_public_input(HashedNewNote)), @@ -202,6 +204,7 @@ impl CallType for WithdrawCallType { ) -> Self::Calldata { use shielder_circuits::circuits::withdraw::WithdrawInstance::*; withdrawNativeCall { + expectedContractVersion: contract_version().to_bytes(), idHiding: field_to_u256(pk.compute_public_input(IdHiding)), amount: field_to_u256(pk.compute_public_input(WithdrawalValue)), withdrawAddress: extra.to, diff --git a/crates/shielder-rust-sdk/src/contract/api.rs b/crates/shielder-rust-sdk/src/contract/api.rs index ea8e0ac7..773c64e6 100644 --- a/crates/shielder-rust-sdk/src/contract/api.rs +++ b/crates/shielder-rust-sdk/src/contract/api.rs @@ -2,10 +2,10 @@ use alloy_primitives::{Address, U256}; use alloy_provider::Provider; use alloy_sol_types::SolCall; -use super::ContractResult; use crate::contract::{ call_type::CallType, connection::{Connection, ConnectionPolicy, NoProvider}, + ContractResult, ShielderContract::{ depositNativeCall, getMerklePathCall, newAccountNativeCall, nullifiersCall, withdrawNativeCall, diff --git a/crates/shielder-rust-sdk/src/contract/mod.rs b/crates/shielder-rust-sdk/src/contract/mod.rs index cf9ffc35..aa803726 100644 --- a/crates/shielder-rust-sdk/src/contract/mod.rs +++ b/crates/shielder-rust-sdk/src/contract/mod.rs @@ -33,6 +33,11 @@ pub enum ShielderContractError { EventNotFound, #[error("Invalid signer: {0:?}")] InvalidSigner(LocalSignerError), + #[error("Contract version does not match sdk version. {version:?} is not compatible with {sdk_version:?}")] + ContractVersionMismatch { + version: ContractVersion, + sdk_version: ContractVersion, + }, #[error("Other error: {0}")] Other(String), } diff --git a/crates/shielder-rust-sdk/src/contract/types.rs b/crates/shielder-rust-sdk/src/contract/types.rs index a9671113..16aea6fe 100644 --- a/crates/shielder-rust-sdk/src/contract/types.rs +++ b/crates/shielder-rust-sdk/src/contract/types.rs @@ -8,13 +8,31 @@ use alloy_primitives::U256; use alloy_sol_types::{sol, SolCall}; use ShielderContract::*; +use crate::{ + contract::ShielderContractError, + version::{contract_version, ContractVersion}, +}; + sol! { #[sol(rpc, all_derives = true)] #[derive(Debug, PartialEq, Eq)] contract ShielderContract { - event NewAccountNative(uint256 idHash, uint256 amount, uint256 newNote, uint256 newNoteIndex); - event DepositNative(uint256 idHiding, uint256 amount, uint256 newNote, uint256 newNoteIndex); + event NewAccountNative( + bytes3 contractVersion, + uint256 idHash, + uint256 amount, + uint256 newNote, + uint256 newNoteIndex + ); + event DepositNative( + bytes3 contractVersion, + uint256 idHiding, + uint256 amount, + uint256 newNote, + uint256 newNoteIndex + ); event WithdrawNative( + bytes3 contractVersion, uint256 idHiding, uint256 amount, address withdrawAddress, @@ -37,6 +55,7 @@ sol! { error ContractBalanceLimitReached(); error LeafIsNotInTheTree(); error PrecompileCallFailed(); + error WrongContractVersion(bytes3 actual, bytes3 expectedByCaller); function depositLimit() external view returns (uint256); @@ -56,8 +75,14 @@ sol! { function pause() external; function unpause() external; - function newAccountNative(uint256 newNote, uint256 idHash, bytes calldata proof) external payable; + function newAccountNative( + bytes3 expectedContractVersion, + uint256 newNote, + uint256 idHash, + bytes calldata proof + ) external payable; function depositNative( + bytes3 expectedContractVersion, uint256 idHiding, uint256 oldNullifierHash, uint256 newNote, @@ -65,6 +90,7 @@ sol! { bytes calldata proof, ) external payable; function withdrawNative( + bytes3 expectedContractVersion, uint256 idHiding, uint256 amount, address withdrawAddress, @@ -102,7 +128,9 @@ sol! { uint256 relayerFee ) external; - function getMerklePath(uint256 id) external view returns (uint256[] memory); + function getMerklePath( + uint256 id + ) external view returns (uint256[] memory); function setDepositLimit(uint256 _depositLimit) external; } @@ -116,6 +144,35 @@ impl ShielderContractEvents { | Self::WithdrawNative(WithdrawNative { newNote: note, .. }) => *note, } } + + pub fn version(&self) -> ContractVersion { + let version = match self { + Self::NewAccountNative(NewAccountNative { + contractVersion, .. + }) + | Self::DepositNative(DepositNative { + contractVersion, .. + }) + | Self::WithdrawNative(WithdrawNative { + contractVersion, .. + }) => contractVersion, + }; + + ContractVersion::from_bytes(*version) + } + + pub fn check_version(&self) -> Result<(), ShielderContractError> { + let version = self.version(); + let sdk_version = contract_version(); + + match version == sdk_version { + true => Ok(()), + false => Err(ShielderContractError::ContractVersionMismatch { + version, + sdk_version, + }), + } + } } // This is a workaround for the lack of support for `#[derive(Clone)]` in `sol!` macro. diff --git a/crates/shielder-rust-sdk/src/lib.rs b/crates/shielder-rust-sdk/src/lib.rs index 87502202..5288099b 100644 --- a/crates/shielder-rust-sdk/src/lib.rs +++ b/crates/shielder-rust-sdk/src/lib.rs @@ -11,7 +11,7 @@ pub mod version { /// The contract version. /// Versioned by note, circuit and patch version. - #[derive(Clone, Copy)] + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct ContractVersion { pub note_version: u8, pub circuit_version: u8, @@ -23,6 +23,14 @@ pub mod version { FixedBytes([self.note_version, self.circuit_version, self.patch_version]) } + pub fn from_bytes(bytes: FixedBytes<3>) -> Self { + Self { + note_version: bytes.0[0], + circuit_version: bytes.0[1], + patch_version: bytes.0[2], + } + } + #[cfg(feature = "account")] pub fn note_version(&self) -> NoteVersion { NoteVersion::new(self.note_version) diff --git a/crates/stress-testing/src/party.rs b/crates/stress-testing/src/party.rs index e91ce4bd..68becde4 100644 --- a/crates/stress-testing/src/party.rs +++ b/crates/stress-testing/src/party.rs @@ -112,6 +112,7 @@ async fn prepare_relay_query( ); let query = RelayQuery { + expected_contract_version: contract_version().to_bytes(), id_hiding: calldata.idHiding, amount: U256::from(WITHDRAW_AMOUNT), withdraw_address: to, diff --git a/tooling-e2e-tests/local_env.sh b/tooling-e2e-tests/local_env.sh index eec543c0..24961d36 100644 --- a/tooling-e2e-tests/local_env.sh +++ b/tooling-e2e-tests/local_env.sh @@ -5,6 +5,8 @@ NODE_RPC_PORT=8545 export NODE_RPC_PORT NODE_RPC_URL="http://localhost:${NODE_RPC_PORT}" export NODE_RPC_URL +CHAIN_ID=31337 +export CHAIN_ID # ====================================================================================================================== # Contract configuration @@ -30,6 +32,11 @@ export CHARLIE_PUBLIC_KEY CHARLIE_PRIVATE_KEY=0xa68e4f75a36d07db56c06b1103c9158801f0f1f24a07deae9324ee86b0753494 # Corresponding private key export CHARLIE_PRIVATE_KEY +TS_SDK_PUBLIC_KEY=0xC881A90D50c4F267AdD6e94720299E31b214aA5C # Random address without any funds by default +export TS_SDK_PUBLIC_KEY +TS_SDK_PRIVATE_KEY=0xbdb9193adbb1dc104b51c09f9cb4456d395ac334324d72c477039bca4a6cad5e # Corresponding private key +export TS_SDK_PRIVATE_KEY + WITHDRAWAL_PUBLIC_KEY=0xCaCA0cf7Ad10377313e391E8eF365c0ED0C51057 # Random address without any funds by default export WITHDRAWAL_PUBLIC_KEY diff --git a/tooling-e2e-tests/testnet_env.sh b/tooling-e2e-tests/testnet_env.sh index 3af72cd1..23155864 100644 --- a/tooling-e2e-tests/testnet_env.sh +++ b/tooling-e2e-tests/testnet_env.sh @@ -6,6 +6,8 @@ # - BOB_PRIVATE_KEY # - CHARLIE_PUBLIC_KEY # - CHARLIE_PRIVATE_KEY +# - TS_SDK_PUBLIC_KEY +# - TS_SDK_PRIVATE_KEY # - FEE_DESTINATION # - FEE_DESTINATION_KEY # - RELAYER_SIGNER_ADDRESSES - as array @@ -14,6 +16,9 @@ NODE_RPC_URL="https://rpc.alephzero-testnet.gelato.digital" export NODE_RPC_URL +CHAIN_ID=2039 +export CHAIN_ID + WITHDRAWAL_PUBLIC_KEY=0xCaCA0cf7Ad10377313e391E8eF365c0ED0C51057 # Random address export WITHDRAWAL_PUBLIC_KEY diff --git a/tooling-e2e-tests/ts_sdk_tests.sh b/tooling-e2e-tests/ts_sdk_tests.sh new file mode 100755 index 00000000..6da7d624 --- /dev/null +++ b/tooling-e2e-tests/ts_sdk_tests.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +ROOT_DIR="${SCRIPT_DIR}/.." + +# Default to multi-threaded if not specified +THREADING=${THREADING:-"mt"} +# Default PLAYWRIGHT_SHARDS to empty string if not set +PLAYWRIGHT_SHARDS=${PLAYWRIGHT_SHARDS:-""} + +if [[ -n "${TESTNET:-}" ]]; then + source "${SCRIPT_DIR}/testnet_env.sh" +else + source "${SCRIPT_DIR}/local_env.sh" +fi +source "${SCRIPT_DIR}/utils.sh" + +scenario() { + cd "${ROOT_DIR}/ts/shielder-sdk-tests" + + # Set config based on threading mode + if [[ "${THREADING}" == "st" ]]; then + PLAYWRIGHT_CONFIG="playwright.singlethreaded.config.mjs" + log_progress "🔄 Running in single-threaded mode" + else + PLAYWRIGHT_CONFIG="playwright.multithreaded.config.mjs" + log_progress "🔄 Running in multi-threaded mode" + fi + + SHIELDER_CONTRACT_ADDRESS=${SHIELDER_CONTRACT_ADDRESS} \ + RPC_HTTP_ENDPOINT=${NODE_RPC_URL} \ + CHAIN_ID=${CHAIN_ID} \ + RELAYER_FEE_ADDRESS=${FEE_DESTINATION} \ + RELAYER_URL=${RELAYER_URL} \ + TESTNET_PRIVATE_KEY=${TS_SDK_PRIVATE_KEY} \ + RELAYER_SIGNER_ADDRESSES=${RELAYER_SIGNER_ADDRESSES} \ + pnpm playwright test --config ${PLAYWRIGHT_CONFIG} ${PLAYWRIGHT_SHARDS} + + cd "${ROOT_DIR}" + log_progress "✅ Success" +} + +run() { + pushd $SCRIPT_DIR/.. &>> output.log + + setup_shielder_sdk + scenario + + popd &>> output.log +} + +trap cleanup EXIT SIGINT SIGTERM +rm -rf output.log +run diff --git a/tooling-e2e-tests/utils.sh b/tooling-e2e-tests/utils.sh index 11af0d52..8e5508b0 100644 --- a/tooling-e2e-tests/utils.sh +++ b/tooling-e2e-tests/utils.sh @@ -40,7 +40,7 @@ start_node() { endow_accounts() { AMOUNT=$(mtzero 100000) - keys=("${ALICE_PUBLIC_KEY}" "${BOB_PUBLIC_KEY}" "${CHARLIE_PUBLIC_KEY}" "${RELAYER_SIGNER_ADDRESSES[@]}") + keys=("${ALICE_PUBLIC_KEY}" "${BOB_PUBLIC_KEY}" "${CHARLIE_PUBLIC_KEY}" "${TS_SDK_PUBLIC_KEY}" "${RELAYER_SIGNER_ADDRESSES[@]}") for key in "${keys[@]}"; do curl "${NODE_RPC_URL}" -X POST -H "Content-Type: application/json" \ --data '{"method":"anvil_setBalance","params":["'"${key}"'", "'${AMOUNT}'"],"id":1,"jsonrpc":"2.0"}' \ @@ -143,6 +143,16 @@ setup() { start_relayer } +setup_shielder_sdk() { + if [[ ! -n "${TESTNET:-}" ]]; then + start_node + endow_accounts + fi + + deploy_contracts + start_relayer +} + cleanup() { if [[ "$?" -ne 0 ]]; then echo -e "❌ Test failed. Printing output.log\n\n\n" diff --git a/ts/shielder-sdk-tests/tests/chain/config.ts b/ts/shielder-sdk-tests/tests/chain/config.ts index 037450d6..e0bd220d 100644 --- a/ts/shielder-sdk-tests/tests/chain/config.ts +++ b/ts/shielder-sdk-tests/tests/chain/config.ts @@ -3,40 +3,44 @@ export const shielderContractAddress = (() => { throw new Error("SHIELDER_CONTRACT_ADDRESS env not defined"); })(); - -export const relayerSignerAddresses = - process.env.RELAYER_SIGNER_ADDRESSES ?? +export const rpcHttpEndpoint = + process.env.RPC_HTTP_ENDPOINT ?? + (() => { + throw new Error("RPC_HTTP_ENDPOINT env not defined"); + })(); +export const relayerFeeAddress = + process.env.RELAYER_FEE_ADDRESS ?? + (() => { + throw new Error("RELAYER_FEE_ADDRESS env not defined"); + })(); +export const relayerUrl = + process.env.RELAYER_URL ?? + (() => { + throw new Error("RELAYER_URL env not defined"); + })(); +export const chainId = + process.env.CHAIN_ID ?? + (() => { + throw new Error("CHAIN_ID env not defined"); + })(); +export const testnetPrivateKey = + process.env.TESTNET_PRIVATE_KEY ?? (() => { - throw new Error("RELAYER_SIGNER_ADDRESSES env not defined"); + throw new Error("TESTNET_PRIVATE_KEY env not defined"); })(); export const getChainConfig = () => { return { - chainId: 31337, - rpcHttpEndpoint: "http://localhost:8545", + chainId: parseInt(chainId), + rpcHttpEndpoint: rpcHttpEndpoint, contractAddress: shielderContractAddress as `0x${string}`, + testnetPrivateKey: testnetPrivateKey as `0x${string}`, }; }; export const getRelayerConfig = () => { return { - address: "0xcaca0a3147bcaf6d7B706Fc5F5c325E6b0e7fb34" as `0x${string}`, - url: "http://localhost:4141", - relayerSignerAddresses: parseAddressesEnv( - relayerSignerAddresses, - ) as `0x${string}`[], + address: relayerFeeAddress as `0x${string}`, + url: relayerUrl, }; }; - -const parseAddressesEnv = (envValue: string | undefined): string[] => { - if (!envValue) { - return []; - } - - // Remove parentheses and split by space - return envValue - .replace(/[()]/g, "") // remove parentheses - .trim() - .split(" ") - .filter(Boolean); // remove empty strings -}; diff --git a/ts/shielder-sdk-tests/tests/chain/contract.test.ts b/ts/shielder-sdk-tests/tests/chain/contract.test.ts deleted file mode 100644 index 339e8f23..00000000 --- a/ts/shielder-sdk-tests/tests/chain/contract.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { expect } from "@playwright/test"; -import { getChainConfig } from "@tests/chain/config"; -import { sdkTest } from "@tests/playwrightTestUtils"; -import { generatePrivateKey } from "viem/accounts"; - -sdkTest("new account and valid merkle path", async ({ workerPage }) => { - const chainConfig = getChainConfig(); - const privateKeyAlice = generatePrivateKey(); - - const isGood = await workerPage.evaluate( - async ({ chainConfig, privateKeyAlice }) => { - // setup - const { alicePublicAccount, contract, aliceSendTransaction } = - await window.chain.testUtils.setupContractTest( - 5n * 10n ** 18n, - chainConfig, - privateKeyAlice, - ); - const newAccountAction = - window.shielder.actions.createNewAccountAction(contract); - - // create new account with initial deposit of 5 coins - const amount = 5n; - const state = await window.state.emptyAccountState(privateKeyAlice); - - // generate calldata for new account action - const newAccountCalldata = await newAccountAction.generateCalldata( - state, - amount, - ); - const { proof, pubInputs } = newAccountCalldata.calldata; - - // send transaction to chain - const contractCalldata = await contract.newAccountCalldata( - alicePublicAccount.account.address, - window.crypto.scalar.scalarToBigint(pubInputs.hNote), - window.crypto.scalar.scalarToBigint(pubInputs.hId), - amount, - proof, - ); - const txHash = await aliceSendTransaction({ - data: contractCalldata, - to: contract.getAddress(), - value: window.crypto.scalar.scalarToBigint(pubInputs.initialDeposit), - }).catch((e) => { - console.error(e); - throw e; - }); - const receipt = await alicePublicAccount.waitForTransactionReceipt({ - hash: txHash, - }); - - // if transaction failed, throw - if (receipt.status !== "success") throw new Error("Transaction failed"); - - // get event of new account creation - const event = await window.chain.testUtils.getEvent( - contract, - state, - receipt.blockNumber, - ); - if (event.amount !== amount) throw new Error("Unexpected amount"); - if ( - event.newNote !== window.crypto.scalar.scalarToBigint(pubInputs.hNote) - ) - throw new Error("Unexpected note"); - - // get and validate merkle path - await window.chain.testUtils.getValidatedMerklePath( - event.newNoteIndex, - contract, - pubInputs.hNote, - ); - return true; - }, - { chainConfig, privateKeyAlice }, - ); - expect(isGood).toBe(true); -}); - -sdkTest("new account failure (bad calldata)", async ({ workerPage }) => { - const chainConfig = getChainConfig(); - const privateKeyAlice = generatePrivateKey(); - - const isGood = await workerPage.evaluate( - async ({ chainConfig, privateKeyAlice }) => { - // setup - const { alicePublicAccount, contract, aliceSendTransaction } = - await window.chain.testUtils.setupContractTest( - 5n * 10n ** 18n, - chainConfig, - privateKeyAlice, - ); - const newAccountAction = - window.shielder.actions.createNewAccountAction(contract); - - // create new account with initial deposit of 5 coins - const amount = 5n; - const state = await window.state.emptyAccountState(privateKeyAlice); - - // generate calldata for new account action - const newAccountCalldata = await newAccountAction.generateCalldata( - state, - amount, - ); - const { proof, pubInputs } = newAccountCalldata.calldata; - - // send transaction to chain - try { - const contractCalldata = await contract.newAccountCalldata( - alicePublicAccount.account.address, - window.crypto.scalar.scalarToBigint(pubInputs.hNote) + 1n, - window.crypto.scalar.scalarToBigint(pubInputs.hId), - amount, - proof, - ); - const txHash = await aliceSendTransaction({ - data: contractCalldata, - to: contract.getAddress(), - value: window.crypto.scalar.scalarToBigint(pubInputs.initialDeposit), - }).catch((e) => { - console.error(e); - throw e; - }); - const receipt = await alicePublicAccount.waitForTransactionReceipt({ - hash: txHash, - }); - - // if transaction failed, throw - if (receipt.status !== "success") throw new Error("Transaction failed"); - } catch (e) { - if ( - (e as Error).message.includes( - 'The contract function "newAccountNative" reverted.', - ) - ) - return true; - throw new Error("Incorrect error message"); - } - throw new Error("Transaction should have failed"); - }, - { chainConfig, privateKeyAlice }, - ); - expect(isGood).toBe(true); -}); - -sdkTest("deposit after new account", async ({ workerPage }) => { - const chainConfig = getChainConfig(); - const privateKeyAlice = generatePrivateKey(); - - const isGood = await workerPage.evaluate( - async ({ chainConfig, privateKeyAlice }) => { - // setup - const { - alicePublicAccount, - contract, - aliceSendTransaction, - shielderClient, - } = await window.chain.testUtils.setupContractTest( - 5n * 10n ** 18n, - chainConfig, - privateKeyAlice, - ); - const depositAction = - window.shielder.actions.createDepositAction(contract); - - // create new account with initial deposit of 5 coins - const initialDepositAmount = 5n; - const newAccountTxHash = await shielderClient.shield( - initialDepositAmount, - aliceSendTransaction, - alicePublicAccount.account.address, - ); - await alicePublicAccount.waitForTransactionReceipt({ - hash: newAccountTxHash, - }); - await shielderClient.syncShielder(); - const stateAfterNewAccount = await shielderClient.accountState(); - - const depositAmount = 3n; - // generate calldata for deposit action - const depositCalldata = await depositAction.generateCalldata( - stateAfterNewAccount, - depositAmount, - ); - - // send deposit transaction to chain - const contractCalldata = await contract.depositCalldata( - alicePublicAccount.account.address, - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.idHiding, - ), - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.hNullifierOld, - ), - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.hNoteNew, - ), - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.merkleRoot, - ), - depositAmount, - depositCalldata.calldata.proof, - ); - const depositTxHash = await aliceSendTransaction({ - data: contractCalldata, - to: contract.getAddress(), - value: depositAmount, - }).catch((e) => { - console.error(e); - throw e; - }); - const depositReceipt = await alicePublicAccount.waitForTransactionReceipt( - { - hash: depositTxHash, - }, - ); - if (depositReceipt.status !== "success") - throw new Error("Transaction failed"); - - // get event of deposit - const depositEvent = await window.chain.testUtils.getEvent( - contract, - stateAfterNewAccount, - depositReceipt.blockNumber, - ); - if (depositEvent.amount !== depositAmount) - throw new Error("Unexpected amount"); - if ( - depositEvent.newNote !== - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.hNoteNew, - ) - ) - throw new Error("Unexpected note"); - return true; - }, - { chainConfig, privateKeyAlice }, - ); - expect(isGood).toBe(true); -}); - -sdkTest( - "deposit after new account failure (bad calldata)", - async ({ workerPage }) => { - const chainConfig = getChainConfig(); - const privateKeyAlice = generatePrivateKey(); - - const isGood = await workerPage.evaluate( - async ({ chainConfig, privateKeyAlice }) => { - // setup - const { - alicePublicAccount, - contract, - aliceSendTransaction, - shielderClient, - } = await window.chain.testUtils.setupContractTest( - 5n * 10n ** 18n, - chainConfig, - privateKeyAlice, - ); - const depositAction = - window.shielder.actions.createDepositAction(contract); - - // create new account with initial deposit of 5 coins - const initialDepositAmount = 5n; - const newAccountTxHash = await shielderClient.shield( - initialDepositAmount, - aliceSendTransaction, - alicePublicAccount.account.address, - ); - await alicePublicAccount.waitForTransactionReceipt({ - hash: newAccountTxHash, - }); - await shielderClient.syncShielder(); - const stateAfterNewAccount = await shielderClient.accountState(); - - const depositAmount = 3n; - // generate calldata for deposit action - const depositCalldata = await depositAction.generateCalldata( - stateAfterNewAccount, - depositAmount, - ); - - try { - // send deposit transaction to chain - const contractCalldata = await contract.depositCalldata( - alicePublicAccount.account.address, - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.idHiding, - ), - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.hNullifierOld, - ), - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.hNoteNew, - ) + 1n, - window.crypto.scalar.scalarToBigint( - depositCalldata.calldata.pubInputs.merkleRoot, - ), - depositAmount, - depositCalldata.calldata.proof, - ); - const depositTxHash = await aliceSendTransaction({ - data: contractCalldata, - to: contract.getAddress(), - value: depositAmount, - }).catch((e) => { - console.error(e); - throw e; - }); - const depositReceipt = - await alicePublicAccount.waitForTransactionReceipt({ - hash: depositTxHash, - }); - if (depositReceipt.status !== "success") - throw new Error("Transaction failed"); - } catch (e) { - if ( - (e as Error).message.includes( - 'The contract function "depositNative" reverted.', - ) - ) - return true; - throw new Error("Incorrect error message"); - } - throw new Error("Transaction should have failed"); - }, - { chainConfig, privateKeyAlice }, - ); - expect(isGood).toBe(true); - }, -); diff --git a/ts/shielder-sdk-tests/tests/chain/contract/deposit.test.ts b/ts/shielder-sdk-tests/tests/chain/contract/deposit.test.ts new file mode 100644 index 00000000..a2846323 --- /dev/null +++ b/ts/shielder-sdk-tests/tests/chain/contract/deposit.test.ts @@ -0,0 +1,242 @@ +import { expect, type JSHandle } from "@playwright/test"; +import { getChainConfig } from "@tests/chain/config"; +import { sdkTest } from "@tests/playwrightTestUtils"; +import { generatePrivateKey } from "viem/accounts"; + +import type { + AccountState, + DepositAction, + DepositCalldata, +} from "shielder-sdk/__internal__"; +import type { ContractTestFixture } from "@/chain/testUtils"; + +// Custom test that creates: +// - `playwrightFixture`: an object initialized outside the browser environment, +// - `webFixture`: a `JSHandle` to an object accessible only in the browser environment. +export const depositTest = sdkTest.extend({ + // eslint-disable-next-line no-empty-pattern + playwrightFixture: async ({ }, use) => { + const playwrightFixture = await createPlaywrightFixture(); + await use(playwrightFixture); + }, + + webFixture: async ({ workerPage, playwrightFixture }, use) => { + const webFixture = await workerPage.evaluateHandle( + createWebFixture, + playwrightFixture, + ); + + await use(webFixture); + + await webFixture.dispose(); + }, +}); + +type Fixtures = { + playwrightFixture: PlaywrightFixture; + webFixture: JSHandle; +}; + +type PlaywrightFixture = { + chainConfig: ReturnType; + privateKeyAlice: `0x${string}`; +}; + +type WebFixture = { + contractTestFixture: ContractTestFixture; + depositAction: DepositAction; + stateAfterNewAccount: AccountState; + depositCalldata: DepositCalldata; + contractCalldata: `0x${string}`; +}; + +async function createPlaywrightFixture() { + const chainConfig = getChainConfig(); + const privateKeyAlice = generatePrivateKey(); + + return { chainConfig, privateKeyAlice }; +} + +async function createWebFixture({ + chainConfig, + privateKeyAlice, +}: PlaywrightFixture) { + const contractTestFixture = await window.chain.testUtils.setupContractTest( + 10n ** 18n, + chainConfig, + privateKeyAlice, + ); + const { alicePublicAccount, contract, aliceSendTransaction, shielderClient } = + contractTestFixture; + const depositAction = window.shielder.actions.createDepositAction(contract); + + const initialDepositAmount = 5n; + const newAccountTxHash = await shielderClient.shield( + initialDepositAmount, + aliceSendTransaction, + alicePublicAccount.account.address, + ); + await alicePublicAccount.waitForTransactionReceipt({ + hash: newAccountTxHash, + }); + await shielderClient.syncShielder(); + const stateAfterNewAccount = await shielderClient.accountState(); + + const depositAmount = 3n; + + const depositCalldata = await depositAction.generateCalldata( + stateAfterNewAccount, + depositAmount, + "0x000001", + ); + + const contractCalldata = await contract.depositCalldata( + "0x000001", + alicePublicAccount.account.address, + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.idHiding, + ), + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.hNullifierOld, + ), + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.hNoteNew, + ), + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.merkleRoot, + ), + depositAmount, + depositCalldata.calldata.proof, + ); + + return { + contractTestFixture, + depositAction, + stateAfterNewAccount, + depositCalldata, + contractCalldata, + }; +} + +depositTest("succeeds", async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ + contractTestFixture, + stateAfterNewAccount, + depositCalldata, + contractCalldata, + }) => { + const { alicePublicAccount, contract, aliceSendTransaction } = + contractTestFixture; + + const depositTxHash = await aliceSendTransaction({ + data: contractCalldata, + to: contract.getAddress(), + value: depositCalldata.amount, + }).catch((e) => { + console.error(e); + throw e; + }); + const depositReceipt = await alicePublicAccount.waitForTransactionReceipt( + { + hash: depositTxHash, + }, + ); + if (depositReceipt.status !== "success") + throw new Error("Transaction failed"); + + // get event of deposit + const depositEvent = await window.chain.testUtils.getEvent( + contract, + stateAfterNewAccount, + depositReceipt.blockNumber, + ); + if (depositEvent.amount !== depositCalldata.amount) + throw new Error("Unexpected amount"); + if ( + depositEvent.newNote !== + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.hNoteNew, + ) + ) + throw new Error("Unexpected note"); + return true; + }, + webFixture, + ); + expect(isGood).toBe(true); +}); + +depositTest("throws if bad calldata", async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ contractTestFixture, depositCalldata }) => { + const { alicePublicAccount, contract } = contractTestFixture; + try { + await contract.depositCalldata( + "0x000001", + alicePublicAccount.account.address, + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.idHiding, + ), + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.hNullifierOld, + ), + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.hNoteNew, + ) + 1n, // introduce error + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.merkleRoot, + ), + depositCalldata.amount, + depositCalldata.calldata.proof, + ); + } catch (e) { + return (e as Error).message.includes( + 'The contract function "depositNative" reverted.', + ); + } + return false; + }, + webFixture, + ); + expect(isGood).toBe(true); +}); + +depositTest( + "throws correct exception if deposit call dry-run receives wrong version", + async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ contractTestFixture, depositCalldata }) => { + const { alicePublicAccount, contract } = contractTestFixture; + try { + await contract.depositCalldata( + "0x000000", // introduce error + alicePublicAccount.account.address, + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.idHiding, + ), + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.hNullifierOld, + ), + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.hNoteNew, + ), + window.crypto.scalar.scalarToBigint( + depositCalldata.calldata.pubInputs.merkleRoot, + ), + depositCalldata.amount, + depositCalldata.calldata.proof, + ); + } catch (e) { + return ( + e instanceof Error && e.message == "Version rejected by contract" + ); + } + + return false; + }, + webFixture, + ); + expect(isGood).toBe(true); + }, +); diff --git a/ts/shielder-sdk-tests/tests/chain/contract/getMerklePath.test.ts b/ts/shielder-sdk-tests/tests/chain/contract/getMerklePath.test.ts new file mode 100644 index 00000000..fd3e2fd3 --- /dev/null +++ b/ts/shielder-sdk-tests/tests/chain/contract/getMerklePath.test.ts @@ -0,0 +1,128 @@ +import { expect, type JSHandle } from "@playwright/test"; +import { getChainConfig } from "@tests/chain/config"; +import { sdkTest } from "@tests/playwrightTestUtils"; +import { generatePrivateKey } from "viem/accounts"; + +import type { Contract, Scalar } from "shielder-sdk/__internal__"; + +// Custom test that creates: +// - `playwrightFixture`: an object initialized outside the browser environment, +// - `webFixture`: a `JSHandle` to an object accessible only in the browser environment. +export const getMerklePathTest = sdkTest.extend({ + // eslint-disable-next-line no-empty-pattern + playwrightFixture: async ({ }, use) => { + const playwrightFixture = await createPlaywrightFixture(); + await use(playwrightFixture); + }, + + webFixture: async ({ workerPage, playwrightFixture }, use) => { + const webFixture = await workerPage.evaluateHandle( + createWebFixture, + playwrightFixture, + ); + + await use(webFixture); + + await webFixture.dispose(); + }, +}); + +type Fixtures = { + playwrightFixture: PlaywrightFixture; + webFixture: JSHandle; +}; + +type PlaywrightFixture = { + chainConfig: ReturnType; + privateKeyAlice: `0x${string}`; +}; + +type WebFixture = { + contract: Contract; + newNoteIndex: bigint; + hNote: Scalar; +}; + +async function createPlaywrightFixture() { + const chainConfig = getChainConfig(); + const privateKeyAlice = generatePrivateKey(); + + return { chainConfig, privateKeyAlice }; +} + +async function createWebFixture({ + chainConfig, + privateKeyAlice, +}: PlaywrightFixture) { + // setup + const { alicePublicAccount, contract, aliceSendTransaction } = + await window.chain.testUtils.setupContractTest( + 10n ** 18n, + chainConfig, + privateKeyAlice, + ); + const newAccountAction = + window.shielder.actions.createNewAccountAction(contract); + + const amount = 5n; + const state = await window.state.emptyAccountState(privateKeyAlice); + + const newAccountCalldata = await newAccountAction.generateCalldata( + state, + amount, + "0x000001", + ); + const { proof, pubInputs } = newAccountCalldata.calldata; + const hNote = pubInputs.hNote; + + const contractCalldata = await contract.newAccountCalldata( + newAccountCalldata.expectedContractVersion, + alicePublicAccount.account.address, + window.crypto.scalar.scalarToBigint(pubInputs.hNote), + window.crypto.scalar.scalarToBigint(pubInputs.hId), + amount, + proof, + ); + + const txHash = await aliceSendTransaction({ + data: contractCalldata, + to: contract.getAddress(), + value: window.crypto.scalar.scalarToBigint(pubInputs.initialDeposit), + }); + const receipt = await alicePublicAccount.waitForTransactionReceipt({ + hash: txHash, + }); + if (receipt.status !== "success") { + throw new Error("Transaction failed"); + } + + const event = await window.chain.testUtils.getValidatedEvent( + contract, + state, + receipt.blockNumber, + amount, + hNote, + ); + const newNoteIndex = event.newNoteIndex; + + return { + contract, + newNoteIndex, + hNote, + }; +} + +getMerklePathTest("succeeds", async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ contract, newNoteIndex, hNote }) => { + await window.chain.testUtils.getValidatedMerklePath( + newNoteIndex, + contract, + hNote, + ); + return true; + }, + webFixture, + ); + expect(isGood).toBe(true); +}); diff --git a/ts/shielder-sdk-tests/tests/chain/contract/newAccount.test.ts b/ts/shielder-sdk-tests/tests/chain/contract/newAccount.test.ts new file mode 100644 index 00000000..ff39543d --- /dev/null +++ b/ts/shielder-sdk-tests/tests/chain/contract/newAccount.test.ts @@ -0,0 +1,202 @@ +import { expect, type JSHandle } from "@playwright/test"; +import { getChainConfig } from "@tests/chain/config"; +import { sdkTest } from "@tests/playwrightTestUtils"; +import { generatePrivateKey } from "viem/accounts"; + +import type { + AccountState, + NewAccountCalldata, +} from "shielder-sdk/__internal__"; +import type { ContractTestFixture } from "@/chain/testUtils"; + +// Custom test that creates: +// - `playwrightFixture`: an object initialized outside the browser environment, +// - `webFixture`: a `JSHandle` to an object accessible only in the browser environment. +export const newAccountTest = sdkTest.extend({ + // eslint-disable-next-line no-empty-pattern + playwrightFixture: async ({ }, use) => { + const playwrightFixture = await createPlaywrightFixture(); + await use(playwrightFixture); + }, + + webFixture: async ({ workerPage, playwrightFixture }, use) => { + const webFixture = await workerPage.evaluateHandle( + createWebFixture, + playwrightFixture, + ); + + await use(webFixture); + + await webFixture.dispose(); + }, +}); + +type Fixtures = { + playwrightFixture: PlaywrightFixture; + webFixture: JSHandle; +}; + +type PlaywrightFixture = { + chainConfig: ReturnType; + privateKeyAlice: `0x${string}`; +}; + +type WebFixture = { + contractTestFixture: ContractTestFixture; + state: AccountState; + newAccountCalldata: NewAccountCalldata; + contractCalldata: `0x${string}`; +}; + +async function createPlaywrightFixture() { + const chainConfig = getChainConfig(); + const privateKeyAlice = generatePrivateKey(); + + return { chainConfig, privateKeyAlice }; +} + +async function createWebFixture({ + chainConfig, + privateKeyAlice, +}: PlaywrightFixture) { + // setup + const contractTestFixture = await window.chain.testUtils.setupContractTest( + 10n ** 18n, + chainConfig, + privateKeyAlice, + ); + const { alicePublicAccount, contract } = contractTestFixture; + const newAccountAction = + window.shielder.actions.createNewAccountAction(contract); + + const amount = 5n; + const state = await window.state.emptyAccountState(privateKeyAlice); + + const newAccountCalldata = await newAccountAction.generateCalldata( + state, + amount, + "0x000001", + ); + const { proof, pubInputs } = newAccountCalldata.calldata; + + const contractCalldata = await contract.newAccountCalldata( + newAccountCalldata.expectedContractVersion, + alicePublicAccount.account.address, + window.crypto.scalar.scalarToBigint(pubInputs.hNote), + window.crypto.scalar.scalarToBigint(pubInputs.hId), + amount, + proof, + ); + + return { + contractTestFixture, + state, + newAccountCalldata, + contractCalldata, + }; +} + +newAccountTest("succeeds", async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ + contractTestFixture, + state, + newAccountCalldata, + contractCalldata, + }) => { + const { contract, alicePublicAccount, aliceSendTransaction } = + contractTestFixture; + + const txHash = await aliceSendTransaction({ + data: contractCalldata, + to: contract.getAddress(), + value: newAccountCalldata.amount, + }); + const receipt = await alicePublicAccount.waitForTransactionReceipt({ + hash: txHash, + }); + if (receipt.status !== "success") { + throw new Error("Transaction failed"); + } + + const event = await window.chain.testUtils.getValidatedEvent( + contract, + state, + receipt.blockNumber, + newAccountCalldata.amount, + newAccountCalldata.calldata.pubInputs.hNote, + ); + await window.chain.testUtils.getValidatedMerklePath( + event.newNoteIndex, + contract, + newAccountCalldata.calldata.pubInputs.hNote, + ); + return true; + }, + webFixture, + ); + expect(isGood).toBe(true); +}); + +newAccountTest("handles bad calldata", async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ contractTestFixture, newAccountCalldata }) => { + const { contract, alicePublicAccount } = contractTestFixture; + + try { + await contract.newAccountCalldata( + newAccountCalldata.expectedContractVersion, + alicePublicAccount.account.address, + window.crypto.scalar.scalarToBigint( + newAccountCalldata.calldata.pubInputs.hNote, + ), + window.crypto.scalar.scalarToBigint( + newAccountCalldata.calldata.pubInputs.hId, + ), + newAccountCalldata.amount + 1n, // introduce error + newAccountCalldata.calldata.proof, + ); + } catch (err) { + return (err as Error).message.includes( + 'The contract function "newAccountNative" reverted.', + ); + } + + return false; + }, + webFixture, + ); + expect(isGood).toBe(true); +}); + +newAccountTest( + "throws correct exception if newAccount dry run reverts due to wrong version", + async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ contractTestFixture, newAccountCalldata }) => { + const { contract, alicePublicAccount } = contractTestFixture; + + try { + await contract.newAccountCalldata( + "0x000000", // wrong version + alicePublicAccount.account.address, + window.crypto.scalar.scalarToBigint( + newAccountCalldata.calldata.pubInputs.hNote, + ), + window.crypto.scalar.scalarToBigint( + newAccountCalldata.calldata.pubInputs.hId, + ), + newAccountCalldata.amount, + newAccountCalldata.calldata.proof, + ); + } catch (err) { + return (err as Error).message == "Version rejected by contract"; + } + + return false; + }, + webFixture, + ); + expect(isGood).toBe(true); + }, +); diff --git a/ts/shielder-sdk-tests/tests/chain/relayer.test.ts b/ts/shielder-sdk-tests/tests/chain/relayer.test.ts index ad2241b9..f9c608b3 100644 --- a/ts/shielder-sdk-tests/tests/chain/relayer.test.ts +++ b/ts/shielder-sdk-tests/tests/chain/relayer.test.ts @@ -1,57 +1,127 @@ -import { expect } from "@playwright/test"; +import type { ContractTestFixture } from "@/chain/testUtils"; +import { expect, type JSHandle } from "@playwright/test"; import { getChainConfig, getRelayerConfig } from "@tests/chain/config"; import { sdkTest } from "@tests/playwrightTestUtils"; +import type { + AccountState, + WithdrawAction, + WithdrawCalldata, +} from "shielder-sdk/__internal__"; import { generatePrivateKey } from "viem/accounts"; -sdkTest("withdraw after new account", async ({ workerPage }) => { +// Custom test that creates: +// - `playwrightFixture`: an object initialized outside the browser environment, +// - `webFixture`: a `JSHandle` to an object accessible only in the browser environment. +export const withdrawTest = sdkTest.extend({ + // eslint-disable-next-line no-empty-pattern + playwrightFixture: async ({}, use) => { + const playwrightFixture = await createPlaywrightFixture(); + await use(playwrightFixture); + }, + + webFixture: async ({ workerPage, playwrightFixture }, use) => { + const webFixture = await workerPage.evaluateHandle( + createWebFixture, + playwrightFixture, + ); + + await use(webFixture); + + await webFixture.dispose(); + }, +}); + +type Fixtures = { + playwrightFixture: PlaywrightFixture; + webFixture: JSHandle; +}; + +type PlaywrightFixture = { + chainConfig: ReturnType; + relayerConfig: ReturnType; + privateKeyAlice: `0x${string}`; +}; + +type WebFixture = { + contractTestFixture: ContractTestFixture; + withdrawAction: WithdrawAction; + stateAfterNewAccount: AccountState; + withdrawAmount: bigint; + addressTo: `0x${string}`; + withdrawCalldata: WithdrawCalldata; +}; + +async function createPlaywrightFixture() { const chainConfig = getChainConfig(); const relayerConfig = getRelayerConfig(); const privateKeyAlice = generatePrivateKey(); - const isGood = await workerPage.evaluate( - async ({ chainConfig, relayerConfig, privateKeyAlice }) => { - // setup - const { - alicePublicAccount, - contract, - relayer, - shielderClient, - aliceSendTransaction, - } = await window.chain.testUtils.setupContractTest( - 5n * 10n ** 18n, - chainConfig, - privateKeyAlice, - relayerConfig, - ); - const withdrawAction = window.shielder.actions.createWithdrawAction( - contract, - relayer!, - ); + return { chainConfig, relayerConfig, privateKeyAlice }; +} - // create new account with initial deposit of 2 coins - const initialDepositAmount = 2n * 10n ** 18n; - const newAccountTxHash = await shielderClient.shield( - initialDepositAmount, - aliceSendTransaction, - alicePublicAccount.account.address, - ); - await alicePublicAccount.waitForTransactionReceipt({ - hash: newAccountTxHash, - }); - await shielderClient.syncShielder(); - const stateAfterNewAccount = await shielderClient.accountState(); - - // withdraw 1 coin - const withdrawAmount = 10n ** 18n; - const addressTo = "0x0000000000000000000000000000000000000001"; - - const withdrawCalldata = await withdrawAction.generateCalldata( - stateAfterNewAccount, - withdrawAmount, - addressTo, - ); +async function createWebFixture({ + chainConfig, + relayerConfig, + privateKeyAlice, +}: PlaywrightFixture) { + const contractTestFixture = await window.chain.testUtils.setupContractTest( + 5n * 10n ** 18n, + chainConfig, + privateKeyAlice, + relayerConfig, + ); + const { + alicePublicAccount, + contract, + relayer, + shielderClient, + aliceSendTransaction, + } = contractTestFixture; + + const withdrawAction = window.shielder.actions.createWithdrawAction( + contract, + relayer!, + ); + + const initialDepositAmount = 2n * 10n ** 18n; + const newAccountTxHash = await shielderClient.shield( + initialDepositAmount, + aliceSendTransaction, + alicePublicAccount.account.address, + ); + await alicePublicAccount.waitForTransactionReceipt({ + hash: newAccountTxHash, + }); + await shielderClient.syncShielder(); + const stateAfterNewAccount = await shielderClient.accountState(); + + const withdrawAmount = 10n ** 18n; + const addressTo: `0x${string}` = "0x0000000000000000000000000000000000000001"; + + const withdrawCalldata = await withdrawAction.generateCalldata( + stateAfterNewAccount, + withdrawAmount, + addressTo, + "0x000001", + ); + + return { + contractTestFixture, + withdrawAction, + stateAfterNewAccount, + withdrawAmount, + addressTo, + withdrawCalldata, + }; +} + +withdrawTest("succeeds", async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ contractTestFixture, withdrawCalldata }) => { + const { alicePublicAccount, relayer } = contractTestFixture; const withdrawResponse = await relayer!.withdraw( + withdrawCalldata.expectedContractVersion, window.crypto.scalar.scalarToBigint( withdrawCalldata.calldata.pubInputs.idHiding, ), @@ -76,73 +146,64 @@ sdkTest("withdraw after new account", async ({ workerPage }) => { throw new Error("Transaction failed"); return true; }, - { chainConfig, relayerConfig, privateKeyAlice }, + webFixture, ); expect(isGood).toBe(true); }); -sdkTest( - "withdraw after new account failure (bad calldata)", - async ({ workerPage }) => { - const chainConfig = getChainConfig(); - const relayerConfig = getRelayerConfig(); - const privateKeyAlice = generatePrivateKey(); - - const isGood = await workerPage.evaluate( - async ({ chainConfig, relayerConfig, privateKeyAlice }) => { - // setup - const { - alicePublicAccount, - contract, - relayer, - shielderClient, - aliceSendTransaction, - } = await window.chain.testUtils.setupContractTest( - 5n * 10n ** 18n, - chainConfig, - privateKeyAlice, - relayerConfig, - ); - const withdrawAction = window.shielder.actions.createWithdrawAction( - contract, - relayer!, - ); +withdrawTest("throws if bad calldata", async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ contractTestFixture, withdrawCalldata }) => { + const { relayer } = contractTestFixture; - // create new account with initial deposit of 2 coins - const initialDepositAmount = 2n * 10n ** 18n; - const newAccountTxHash = await shielderClient.shield( - initialDepositAmount, - aliceSendTransaction, - alicePublicAccount.account.address, + try { + await relayer!.withdraw( + withdrawCalldata.expectedContractVersion, + window.crypto.scalar.scalarToBigint( + withdrawCalldata.calldata.pubInputs.idHiding, + ), + window.crypto.scalar.scalarToBigint( + withdrawCalldata.calldata.pubInputs.hNullifierOld, + ), + window.crypto.scalar.scalarToBigint( + withdrawCalldata.calldata.pubInputs.hNoteNew, + ), + window.crypto.scalar.scalarToBigint( + withdrawCalldata.calldata.pubInputs.merkleRoot, + ), + withdrawCalldata.amount + 1n, // bad calldata + withdrawCalldata.calldata.proof, + withdrawCalldata.address, ); - await alicePublicAccount.waitForTransactionReceipt({ - hash: newAccountTxHash, - }); - await shielderClient.syncShielder(); - const stateAfterNewAccount = await shielderClient.accountState(); - - // withdraw 1 coin - const withdrawAmount = 10n ** 18n; - const addressTo = "0x0000000000000000000000000000000000000001"; + } catch (err) { + return (err as Error).message.includes("Failed to withdraw"); + } + return false; + }, + webFixture, + ); + expect(isGood).toBe(true); +}); - const withdrawCalldata = await withdrawAction.generateCalldata( - stateAfterNewAccount, - withdrawAmount, - addressTo, - ); +withdrawTest( + "throws correct error if relayer receives wrong version", + async ({ workerPage, webFixture }) => { + const isGood = await workerPage.evaluate( + async ({ contractTestFixture, withdrawCalldata }) => { + const { relayer } = contractTestFixture; try { await relayer!.withdraw( + "0x123456", window.crypto.scalar.scalarToBigint( withdrawCalldata.calldata.pubInputs.idHiding, ), window.crypto.scalar.scalarToBigint( withdrawCalldata.calldata.pubInputs.hNullifierOld, ), - // Bad calldata window.crypto.scalar.scalarToBigint( withdrawCalldata.calldata.pubInputs.hNoteNew, - ) + 1n, + ), window.crypto.scalar.scalarToBigint( withdrawCalldata.calldata.pubInputs.merkleRoot, ), @@ -150,13 +211,16 @@ sdkTest( withdrawCalldata.calldata.proof, withdrawCalldata.address, ); - } catch (e: unknown) { - if ((e as Error).message.includes("Failed to withdraw")) return true; - throw new Error("Incorrect error message"); + } catch (err) { + const expectedMessage = + "Version rejected by relayer: " + + '"Version mismatch: ' + + 'relayer expects 0x000001, client expects 0x123456"'; + return err instanceof Error && err.message == expectedMessage; } - throw new Error("Transaction should have failed"); + return false; }, - { chainConfig, relayerConfig, privateKeyAlice }, + webFixture, ); expect(isGood).toBe(true); }, diff --git a/ts/shielder-sdk-tests/tests/shielder/client.test.ts b/ts/shielder-sdk-tests/tests/shielder/client.test.ts index 6359d680..4be46c73 100644 --- a/ts/shielder-sdk-tests/tests/shielder/client.test.ts +++ b/ts/shielder-sdk-tests/tests/shielder/client.test.ts @@ -3,6 +3,9 @@ import { sdkTest } from "@tests/playwrightTestUtils"; import type { Calldata, ShielderOperation } from "shielder-sdk/__internal__"; import { generatePrivateKey, privateKeyToAddress } from "viem/accounts"; +// TODO(ZK-572): add tests to confirm that all wrong version code paths +// result in producing the correct error for the frontend. + sdkTest("new account, validate positive callbacks", async ({ workerPage }) => { const privateKeyAlice = generatePrivateKey(); const aliceAddress = privateKeyToAddress(privateKeyAlice); diff --git a/ts/shielder-sdk-tests/web/EntryPoint.tsx b/ts/shielder-sdk-tests/web/EntryPoint.tsx index 3d2b928e..0b5e48b8 100644 --- a/ts/shielder-sdk-tests/web/EntryPoint.tsx +++ b/ts/shielder-sdk-tests/web/EntryPoint.tsx @@ -54,8 +54,9 @@ import { import type { Address } from "viem"; import { generatePrivateKey } from "viem/accounts"; import { - DevnetManager, + BalanceManager, getEvent, + getValidatedEvent, getValidatedMerklePath, setupContractTest, type ContractTestFixture, @@ -125,10 +126,11 @@ declare global { createRelayer: (url: string, address: Address) => Relayer; testUtils: { - createDevnetManager: ( + createBalanceManager: ( chainId: number, rpcHttpEndpoint: string, - ) => DevnetManager; + testnetPrivateKey: `0x${string}`, + ) => BalanceManager; getValidatedMerklePath: ( merkleTreeIdx: bigint, contract: Contract, @@ -139,18 +141,25 @@ declare global { state: AccountState, blockNumber: bigint, ) => Promise; + getValidatedEvent: ( + contract: Contract, + state: AccountState, + blockNumber: bigint, + expectedAmount: bigint, + expectedNewNote: Scalar, + ) => Promise; setupContractTest: ( initialPublicBalance: bigint, chainConfig: { chainId: number; rpcHttpEndpoint: string; contractAddress: `0x${string}`; + testnetPrivateKey: `0x${string}`; }, privateKeyAlice: `0x${string}`, relayerConfig?: { address: `0x${string}`; url: string; - relayerSignerAddresses: `0x${string}`[]; }, ) => Promise; }; @@ -255,12 +264,14 @@ function EntryPoint() { new Relayer(url, address); window.chain.testUtils = window.chain.testUtils || {}; - window.chain.testUtils.createDevnetManager = ( + window.chain.testUtils.createBalanceManager = ( chainId: number, rpcHttpEndpoint: string, - ) => new DevnetManager(chainId, rpcHttpEndpoint); + testnetPrivateKey: `0x${string}`, + ) => new BalanceManager(chainId, rpcHttpEndpoint, testnetPrivateKey); window.chain.testUtils.getValidatedMerklePath = getValidatedMerklePath; window.chain.testUtils.getEvent = getEvent; + window.chain.testUtils.getValidatedEvent = getValidatedEvent; window.chain.testUtils.setupContractTest = setupContractTest; // Expose state utilities window.state = window.state || {}; diff --git a/ts/shielder-sdk-tests/web/chain/testUtils.ts b/ts/shielder-sdk-tests/web/chain/testUtils.ts index 7565296a..d137a81c 100644 --- a/ts/shielder-sdk-tests/web/chain/testUtils.ts +++ b/ts/shielder-sdk-tests/web/chain/testUtils.ts @@ -2,6 +2,7 @@ import { Contract, Relayer, Scalar, + scalarToBigint, ShielderClient, stateChangingEvents, type AccountState, @@ -16,6 +17,7 @@ import { defineChain, http, publicActions, + walletActions, TransactionExecutionError, type Chain, type HttpTransport, @@ -29,24 +31,31 @@ import { import { privateKeyToAccount } from "viem/accounts"; import { createNonceManager, jsonRpc } from "viem/nonce"; -const chainName = "devnet"; +const chainName = "azero"; const chainNativeCurrency = { name: "AZERO", symbol: "AZERO", decimals: 18, }; -export class DevnetManager { - testClient: TestClient; +export class BalanceManager { + testClient: TestClient & + WalletClient & + PublicClient; /** * * @param privateKey use private key prefilled with funds - * @param chainId devnet chain id - * @param rpcHttpEndpoint devnet rpc endpoint + * @param chainId chain id + * @param rpcHttpEndpoint rpc endpoint */ - constructor(chainId: number, rpcHttpEndpoint: string) { + constructor( + chainId: number, + rpcHttpEndpoint: string, + testnetPrivateKey: `0x${string}`, + ) { this.testClient = createTestClient({ + account: privateKeyToAccount(testnetPrivateKey), chain: defineChain({ name: chainName, id: chainId, @@ -59,14 +68,29 @@ export class DevnetManager { }), mode: "anvil", transport: http(), - }); + }) + .extend(publicActions) + .extend(walletActions); } async setBalance(address: `0x${string}`, value: bigint) { - await this.testClient.setBalance({ - address, + if (this.testClient.chain.rpcUrls.default.http[0].includes("localhost")) { + await this.testClient.setBalance({ + address, + value, + }); + return; + } + const txHash = await this.testClient.sendTransaction({ + to: address, value, }); + const receipt = await this.testClient.waitForTransactionReceipt({ + hash: txHash, + }); + if (receipt.status !== "success") { + throw new Error("Faucet failed"); + } } } @@ -75,7 +99,7 @@ export interface ContractTestFixture { shielderClient: ShielderClient; relayer?: Relayer; alicePublicAccount: SeededAccount; - devnetManager: DevnetManager; + balanceManager: BalanceManager; storage: InjectedStorageInterface; aliceSendTransaction: SendShielderTransaction; } @@ -86,17 +110,18 @@ export const setupContractTest = async ( chainId: number; rpcHttpEndpoint: string; contractAddress: `0x${string}`; + testnetPrivateKey: `0x${string}`; }, privateKeyAlice: `0x${string}`, relayerConfig?: { address: `0x${string}`; url: string; - relayerSignerAddresses: `0x${string}`[]; }, ): Promise => { - const devnetManager = window.chain.testUtils.createDevnetManager( + const balanceManager = window.chain.testUtils.createBalanceManager( chainConfig.chainId, chainConfig.rpcHttpEndpoint, + chainConfig.testnetPrivateKey, ); const alicePublicAccount: SeededAccount = createAccount( privateKeyAlice, @@ -116,15 +141,10 @@ export const setupContractTest = async ( }), transport: http(), }); - await devnetManager.setBalance( + await balanceManager.setBalance( alicePublicAccount.account.address, initialPublicBalance, ); - if (relayerConfig) { - for (const relayerAddress of relayerConfig.relayerSignerAddresses) { - await devnetManager.setBalance(relayerAddress, 5n * 10n ** 18n); - } - } const contract = window.chain.createContract( publicClient, chainConfig.contractAddress, @@ -174,7 +194,7 @@ export const setupContractTest = async ( shielderClient, relayer, alicePublicAccount, - devnetManager, + balanceManager: balanceManager, storage, aliceSendTransaction, }; @@ -197,6 +217,23 @@ export const getEvent = async ( return event; }; +export const getValidatedEvent = async ( + contract: Contract, + state: AccountState, + blockNumber: bigint, + expectedAmount: bigint, + expectedNewNote: Scalar, +) => { + const event = await getEvent(contract, state, blockNumber); + if (event.amount !== expectedAmount) { + throw new Error("Unexpected amount"); + } + if (event.newNote !== scalarToBigint(expectedNewNote)) { + throw new Error("Unexpected note"); + } + return event; +}; + export const getValidatedMerklePath = async ( merkleTreeIdx: bigint, contract: Contract, diff --git a/ts/shielder-sdk-tests/web/shielder/testUtils.ts b/ts/shielder-sdk-tests/web/shielder/testUtils.ts index cd09506e..9acfd77b 100644 --- a/ts/shielder-sdk-tests/web/shielder/testUtils.ts +++ b/ts/shielder-sdk-tests/web/shielder/testUtils.ts @@ -29,6 +29,7 @@ export class MockedContract implements IContract { return this.merklePaths.get(idx)!; }; newAccountCalldata = async ( + _expectedContractVersion: `0x${string}`, _from: `0x${string}`, _newNote: bigint, _idHash: bigint, @@ -41,6 +42,7 @@ export class MockedContract implements IContract { return this.txHashToReturn; }; depositCalldata = async ( + _expectedContractVersion: `0x${string}`, _from: `0x${string}`, _idHiding: bigint, _oldNoteNullifierHash: bigint, @@ -52,6 +54,7 @@ export class MockedContract implements IContract { throw new Error("Not implemented"); }; withdraw = async ( + _expectedContractVersion: `0x${string}`, _idHiding: bigint, _oldNullifierHash: bigint, _newNote: bigint, @@ -79,6 +82,7 @@ export class MockedRelayer implements IRelayer { this.address = address; } withdraw = async ( + _expectedContractVersion: `0x${string}`, _idHiding: bigint, _oldNullifierHash: bigint, _newNote: bigint, diff --git a/ts/shielder-sdk/package.json b/ts/shielder-sdk/package.json index afed1268..c25fdf77 100644 --- a/ts/shielder-sdk/package.json +++ b/ts/shielder-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@cardinal-cryptography/shielder-sdk", - "version": "0.0.7", + "version": "0.0.8", "description": "A web package for interacting with Shielder, a part of zkOS privacy engine.", "license": "Apache-2.0", "keywords": [ diff --git a/ts/shielder-sdk/src/chain/contract.ts b/ts/shielder-sdk/src/chain/contract.ts index 99dbd111..1af93c2e 100644 --- a/ts/shielder-sdk/src/chain/contract.ts +++ b/ts/shielder-sdk/src/chain/contract.ts @@ -7,12 +7,45 @@ import { Hash, PublicClient } from "viem"; +import { BaseError, ContractFunctionRevertedError } from "viem"; import { abi } from "../_generated/abi"; import { shieldActionGasLimit } from "@/constants"; +export class VersionRejectedByContract extends Error { + constructor() { + super("Version rejected by contract"); + + Object.setPrototypeOf(this, VersionRejectedByContract.prototype); + } +} + +export async function handleWrongContractVersionError( + func: () => Promise +): Promise { + try { + return await func(); + } catch (err) { + // Following advice from + // https://viem.sh/docs/contract/simulateContract#handling-custom-errors + if (err instanceof BaseError) { + const revertError = err.walk( + (err) => err instanceof ContractFunctionRevertedError + ); + if (revertError instanceof ContractFunctionRevertedError) { + const errorName = revertError.data?.errorName ?? ""; + if (errorName === "WrongContractVersion") { + throw new VersionRejectedByContract(); + } + } + } + throw err; + } +} + export type NoteEvent = { name: "NewAccountNative" | "DepositNative" | "WithdrawNative"; + contractVersion: `0x${string}`; amount: bigint; newNoteIndex: bigint; newNote: bigint; @@ -36,6 +69,7 @@ export type IContract = { getAddress: () => Address; getMerklePath: (idx: bigint) => Promise; newAccountCalldata: ( + expectedContractVersion: `0x${string}`, from: Address, newNote: bigint, idHash: bigint, @@ -43,6 +77,7 @@ export type IContract = { proof: Uint8Array ) => Promise<`0x${string}`>; depositCalldata: ( + expectedContractVersion: `0x${string}`, from: Address, idHiding: bigint, oldNoteNullifierHash: bigint, @@ -69,32 +104,34 @@ export class Contract implements IContract { }; getMerklePath = async (idx: bigint): Promise => { - return (await this.contract.read.getMerklePath([idx])) as readonly bigint[]; + const merklePath = await this.contract.read.getMerklePath([idx]); + + return merklePath as readonly bigint[]; }; newAccountCalldata = async ( + expectedContractVersion: `0x${string}`, from: Address, newNote: bigint, idHash: bigint, amount: bigint, proof: Uint8Array ) => { - await this.contract.simulate.newAccountNative( - [newNote, idHash, bytesToHex(proof)], - { - account: from, - value: amount, - gas: shieldActionGasLimit - } - ); + await handleWrongContractVersionError(() => { + return this.contract.simulate.newAccountNative( + [expectedContractVersion, newNote, idHash, bytesToHex(proof)], + { account: from, value: amount, gas: shieldActionGasLimit } + ); + }); return encodeFunctionData({ abi, functionName: "newAccountNative", - args: [newNote, idHash, bytesToHex(proof)] + args: [expectedContractVersion, newNote, idHash, bytesToHex(proof)] }); }; depositCalldata = async ( + expectedContractVersion: `0x${string}`, from: Address, idHiding: bigint, oldNoteNullifierHash: bigint, @@ -103,14 +140,24 @@ export class Contract implements IContract { amount: bigint, proof: Uint8Array ) => { - await this.contract.simulate.depositNative( - [idHiding, oldNoteNullifierHash, newNote, merkleRoot, bytesToHex(proof)], - { account: from, value: amount, gas: shieldActionGasLimit } - ); + await handleWrongContractVersionError(() => { + return this.contract.simulate.depositNative( + [ + expectedContractVersion, + idHiding, + oldNoteNullifierHash, + newNote, + merkleRoot, + bytesToHex(proof) + ], + { account: from, value: amount, gas: shieldActionGasLimit } + ); + }); return encodeFunctionData({ abi, functionName: "depositNative", args: [ + expectedContractVersion, idHiding, oldNoteNullifierHash, newNote, @@ -162,6 +209,7 @@ export class Contract implements IContract { ].map((event) => { return { name: event.eventName, + contractVersion: event.args.contractVersion, amount: event.args.amount!, newNoteIndex: event.args.newNoteIndex!, newNote: event.args.newNote!, diff --git a/ts/shielder-sdk/src/chain/relayer.ts b/ts/shielder-sdk/src/chain/relayer.ts index 8f747ed0..88e6ca7d 100644 --- a/ts/shielder-sdk/src/chain/relayer.ts +++ b/ts/shielder-sdk/src/chain/relayer.ts @@ -6,9 +6,26 @@ export type WithdrawResponse = { block_hash: Hash; }; +export class VersionRejectedByRelayer extends Error { + constructor(message: string) { + super(`Version rejected by relayer: ${message}`); + + Object.setPrototypeOf(this, VersionRejectedByRelayer.prototype); + } +} + +export class GenericWithdrawError extends Error { + constructor(message: string) { + super(`Failed to withdraw: ${message}`); + + Object.setPrototypeOf(this, GenericWithdrawError.prototype); + } +} + export type IRelayer = { address: Address; withdraw: ( + expectedContractVersion: `0x${string}`, idHiding: bigint, oldNullifierHash: bigint, newNote: bigint, @@ -29,6 +46,7 @@ export class Relayer implements IRelayer { } withdraw = async ( + expectedContractVersion: `0x${string}`, idHiding: bigint, oldNullifierHash: bigint, newNote: bigint, @@ -37,14 +55,16 @@ export class Relayer implements IRelayer { proof: Uint8Array, withdrawAddress: `0x${string}` ) => { + let response; try { - const response = await fetch(`${this.url}${relayPath}`, { + response = await fetch(`${this.url}${relayPath}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify( { + expected_contract_version: expectedContractVersion, id_hiding: idHiding, amount, withdraw_address: withdrawAddress, @@ -57,12 +77,20 @@ export class Relayer implements IRelayer { typeof value === "bigint" ? value.toString() : value ) }); - if (!response.ok) { - throw new Error(`${await response.text()}`); - } - return (await response.json()) as WithdrawResponse; } catch (error) { - throw new Error(`Failed to withdraw: ${(error as Error).message}`); + throw new GenericWithdrawError(`${(error as Error).message}`); } + + if (!response.ok) { + const responseText = await response.text(); + + if (responseText.startsWith('"Version mismatch:')) { + throw new VersionRejectedByRelayer(responseText); + } + + throw new GenericWithdrawError(`${responseText}`); + } + + return (await response.json()) as WithdrawResponse; }; } diff --git a/ts/shielder-sdk/src/shielder/actions/deposit.ts b/ts/shielder-sdk/src/shielder/actions/deposit.ts index 741a25ed..c8a935f6 100644 --- a/ts/shielder-sdk/src/shielder/actions/deposit.ts +++ b/ts/shielder-sdk/src/shielder/actions/deposit.ts @@ -9,6 +9,7 @@ import { wasmClientWorker } from "@/wasmClientWorker"; export interface DepositCalldata extends Calldata { calldata: DepositReturn; + expectedContractVersion: `0x${string}`; amount: bigint; merkleRoot: Scalar; } @@ -45,7 +46,8 @@ export class DepositAction { */ async generateCalldata( state: AccountState, - amount: bigint + amount: bigint, + expectedContractVersion: `0x${string}` ): Promise { const lastNodeIndex = state.currentNoteIndex!; const [path, merkleRoot] = await wasmClientWorker.merklePathAndRoot( @@ -88,6 +90,7 @@ export class DepositAction { const provingTime = Date.now() - time; return { calldata, + expectedContractVersion, provingTimeMillis: provingTime, amount, merkleRoot @@ -112,6 +115,7 @@ export class DepositAction { merkleRoot } = calldata; const encodedCalldata = await this.contract.depositCalldata( + calldata.expectedContractVersion, from, scalarToBigint(pubInputs.idHiding), scalarToBigint(pubInputs.hNullifierOld), diff --git a/ts/shielder-sdk/src/shielder/actions/newAccount.ts b/ts/shielder-sdk/src/shielder/actions/newAccount.ts index 2f053100..ed9a4b0b 100644 --- a/ts/shielder-sdk/src/shielder/actions/newAccount.ts +++ b/ts/shielder-sdk/src/shielder/actions/newAccount.ts @@ -8,6 +8,7 @@ import { wasmClientWorker } from "@/wasmClientWorker"; export interface NewAccountCalldata { calldata: NewAccountReturn; + expectedContractVersion: `0x${string}`; provingTimeMillis: number; amount: bigint; } @@ -44,7 +45,8 @@ export class NewAccountAction { */ async generateCalldata( state: AccountState, - amount: bigint + amount: bigint, + expectedContractVersion: `0x${string}` ): Promise { const { nullifier, trapdoor } = await wasmClientWorker.getSecrets( state.id, @@ -64,6 +66,7 @@ export class NewAccountAction { }); const provingTime = Date.now() - time; return { + expectedContractVersion, calldata, provingTimeMillis: provingTime, amount @@ -84,9 +87,11 @@ export class NewAccountAction { ) { const { calldata: { pubInputs, proof }, + expectedContractVersion, amount } = calldata; const encodedCalldata = await this.contract.newAccountCalldata( + expectedContractVersion, from, scalarToBigint(pubInputs.hNote), scalarToBigint(pubInputs.hId), diff --git a/ts/shielder-sdk/src/shielder/actions/withdraw.ts b/ts/shielder-sdk/src/shielder/actions/withdraw.ts index eb1a9083..7aa5cd18 100644 --- a/ts/shielder-sdk/src/shielder/actions/withdraw.ts +++ b/ts/shielder-sdk/src/shielder/actions/withdraw.ts @@ -9,6 +9,7 @@ import { rawAction } from "@/shielder/actions/utils"; import { WithdrawReturn } from "@/crypto/circuits/withdraw"; export interface WithdrawCalldata { + expectedContractVersion: `0x${string}`; calldata: WithdrawReturn; provingTimeMillis: number; amount: bigint; @@ -55,7 +56,8 @@ export class WithdrawAction { async generateCalldata( state: AccountState, amount: bigint, - address: Address + address: Address, + expectedContractVersion: `0x${string}` ): Promise { const lastNodeIndex = state.currentNoteIndex!; const [path, merkleRoot] = await wasmClientWorker.merklePathAndRoot( @@ -108,6 +110,7 @@ export class WithdrawAction { }); const provingTime = Date.now() - time; return { + expectedContractVersion, calldata, provingTimeMillis: provingTime, amount, @@ -124,6 +127,7 @@ export class WithdrawAction { */ async sendCalldata(calldata: WithdrawCalldata) { const { + expectedContractVersion, calldata: { pubInputs, proof }, amount, address, @@ -131,6 +135,7 @@ export class WithdrawAction { } = calldata; const { tx_hash: txHash } = await this.relayer .withdraw( + expectedContractVersion, scalarToBigint(pubInputs.idHiding), scalarToBigint(pubInputs.hNullifierOld), scalarToBigint(pubInputs.hNoteNew), diff --git a/ts/shielder-sdk/src/shielder/client.ts b/ts/shielder-sdk/src/shielder/client.ts index d7772828..8c11eef7 100644 --- a/ts/shielder-sdk/src/shielder/client.ts +++ b/ts/shielder-sdk/src/shielder/client.ts @@ -18,6 +18,7 @@ import { InjectedStorageInterface } from "@/shielder/state/storageSchema"; import { Calldata } from "@/shielder/actions"; +import { contractVersion } from "@/constants"; export type ShielderOperation = "shield" | "withdraw"; @@ -115,6 +116,9 @@ export const createShielderClient = ( ); }; +// TODO(ZK-572): handle wrong version exceptions and produce a single +// `OutdatedSdk` exception for the frontend. + export class ShielderClient { private stateManager: StateManager; private stateSynchronizer: StateSynchronizer; @@ -227,7 +231,13 @@ export class ShielderClient { async withdraw(amount: bigint, address: Address) { const state = await this.stateManager.accountState(); const txHash = await this.handleCalldata( - () => this.withdrawAction.generateCalldata(state, amount, address), + () => + this.withdrawAction.generateCalldata( + state, + amount, + address, + contractVersion + ), (calldata) => this.withdrawAction.sendCalldata(calldata), "withdraw" ); @@ -245,7 +255,8 @@ export class ShielderClient { ) { const state = await this.stateManager.accountState(); const txHash = await this.handleCalldata( - () => this.newAccountAction.generateCalldata(state, amount), + () => + this.newAccountAction.generateCalldata(state, amount, contractVersion), (calldata) => this.newAccountAction.sendCalldata( calldata, @@ -264,7 +275,7 @@ export class ShielderClient { ) { const state = await this.stateManager.accountState(); const txHash = await this.handleCalldata( - () => this.depositAction.generateCalldata(state, amount), + () => this.depositAction.generateCalldata(state, amount, contractVersion), (calldata) => this.depositAction.sendCalldata( calldata, diff --git a/ts/shielder-sdk/src/shielder/state/sync.ts b/ts/shielder-sdk/src/shielder/state/sync.ts index 07f10757..ec50eca0 100644 --- a/ts/shielder-sdk/src/shielder/state/sync.ts +++ b/ts/shielder-sdk/src/shielder/state/sync.ts @@ -12,6 +12,7 @@ import { } from "@/shielder/state/chainEvents"; import { wasmClientWorker } from "@/wasmClientWorker"; import { Mutex } from "async-mutex"; +import { isVersionSupported } from "@/utils"; export class StateSynchronizer { contract: IContract; @@ -73,20 +74,37 @@ export class StateSynchronizer { } } + private async getNullifier(state: AccountState) { + if (state.nonce > 0n) { + return (await wasmClientWorker.getSecrets(state.id, state.nonce - 1n)) + .nullifier; + } + return state.id; + } + + private async getNoteEventForBlock(state: AccountState, block: bigint) { + const events = await stateChangingEvents( + state, + await this.contract.getNoteEventsFromBlock(block) + ); + + if (events.length != 1) { + console.error(events); + throw new Error( + `Unexpected number of events: ${events.length}, expected 1 event` + ); + } + + return events[0]; + } + /** * Finds the next state transition event for the given state, emitted in shielder contract. * @param state - account state * @returns the next state transition event */ private async findStateTransitionEvent(state: AccountState) { - let nullifier; - if (state.nonce > 0n) { - nullifier = ( - await wasmClientWorker.getSecrets(state.id, state.nonce - 1n) - ).nullifier; - } else { - nullifier = state.id; - } + const nullifier = await this.getNullifier(state); const block = await this.contract.nullifierBlock( scalarToBigint(await wasmClientWorker.poseidonHash([nullifier])) @@ -96,18 +114,12 @@ export class StateSynchronizer { return null; } - const events = await stateChangingEvents( - state, - await this.contract.getNoteEventsFromBlock(block) - ); + const event = await this.getNoteEventForBlock(state, block); - if (events.length != 1) { - console.error(events); - throw new Error(`Unexpected number of events: ${events.length}`); + if (!isVersionSupported(event.contractVersion)) { + throw new Error(`Unexpected version: ${event.contractVersion}`); } - const event = events[0]; - return event; } } diff --git a/ts/shielder-sdk/src/utils.ts b/ts/shielder-sdk/src/utils.ts index 93ffcb6c..ab8267ae 100644 --- a/ts/shielder-sdk/src/utils.ts +++ b/ts/shielder-sdk/src/utils.ts @@ -21,3 +21,7 @@ export function noteVersion() { // So we need to take 3rd byte, we do so by shifting the number by 16 bits to the right. return Scalar.fromBigint(BigInt(contractVersion) >> 16n); } + +export function isVersionSupported(version: `0x${string}`) { + return version === contractVersion; +}