matter-labs / zksync-sso-clave-contracts

ZKsync SSO Contracts based on Clave
GNU General Public License v3.0
7 stars 1 forks source link

[Request]: Add Two-Factor Authentication (2FA) Validator Module #211

Open MexicanAce opened 17 hours ago

MexicanAce commented 17 hours ago

πŸ“ Description

We'd like to introduce a ECDSA Two-Factor Authentication (2FA) Validator Module for ERC-7579 and Clave smart accounts.

NOTE: This work (including code snippets below) was originally proposed/completed by @alexandrecarvalheira

The 2FA Validator Hook provides the following key features:

  1. Configurable daily spending threshold
  2. Secondary validation (currently ECDSA) for:
    • Transactions exceeding the daily limit
    • Critical operations (e.g., changing settings)
  3. Flexible design allowing for future adaptation of the secondary validation method

Key Components:

πŸ€” Rationale

The module enhances account security by implementing a daily spending limit and requiring additional validation for high-value transactions or sensitive operations.

πŸ“‹ Additional context

Original PR: https://github.com/MexicanAce/zksync-account/pull/1

TODOs

File changes:

ClaveAccount.sol

    //FIX: not doing proper main validation while passkeysigner
    // bool valid = _handleValidation(validator, signedHash, signature);
    bool valid = true;

interfaces/IPoolFactory.sol

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

interface IPoolFactory {
  function getPool(address tokenA, address tokenB) external view returns (address pool);
}

interface IPool {
  function getAmountOut(address tokenIn, uint amountIn, address sender) external view returns (uint amountOut);
}

managers/ModuleManager.sol

  //FIX: this modulelinkedList seems to never be used
  function _modulesLinkedList() private view returns (mapping(address => address) storage modules) {
    modules = ClaveStorage.layout().modules;
  }

validators/2FAValidatorModule.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../interfaces/IERC7579Module.sol";

import { IValidationHook } from "../interfaces/IHook.sol";
import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import { Transaction, TransactionHelper } from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol";
import { IERC7579Module } from "../interfaces/IERC7579Module.sol";
import { IUserOpValidator } from "../interfaces/IERC7579Validator.sol";
import { IR1Validator } from "../interfaces/IValidator.sol";
import { IPoolFactory, IPool } from "../interfaces/IPoolFactory.sol";

import { Utils } from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/Utils.sol";

/**
 * This contract implements a Two-Factor Authentication (2FA) Validator Module for ERC-7579 and Clave smart accounts.
 * It provides the following key features:
 * 1. Daily spending limit: Tracks and enforces a configurable daily spending threshold for the account.
 * 2. Secondary validation: Requires an additional ECDSA signature (2FA) when:
 *    a) The daily spending limit is exceeded.
 *    b) Certain critical operations are performed (e.g., changing settings).
 * 3. Flexible authentication: While currently using ECDSA, the secondary validation method can be adapted.
 *
 * This module enhances account security by adding an extra layer of verification for high-value or sensitive transactions,
 * while allowing routine operations to proceed without additional friction up to the daily limit.
 */
contract TwoFAValidatorModule is IERC7579Module, IValidationHook {
  /*//////////////////////////////////////////////////////////////////////////
                            CONSTANTS & STORAGE
    //////////////////////////////////////////////////////////////////////////*/

  event ModuleInitialized(address indexed account);
  event ModuleUninitialized(address indexed account);
  event ConfigSet(address indexed account, address indexed token);
  event ThresholdSet(address indexed account, uint256 threshold);

  error InvalidSigner(address signer);
  error InvalidSignature();

  //TODO: not tested
  address poolFactory = 0x0a34FBDf37C246C0B401da5f00ABd6529d906193;
  address WETH = 0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91;

  struct Config {
    address signer;
    uint256 threshold;
  }

  // token address to its threshold to require the owner signature
  mapping(address account => Config) public config;

  // spent amount by account on the timeperiod
  mapping(address account => mapping(uint256 timeperiod => uint256 amount)) public dailySpent;
  /*//////////////////////////////////////////////////////////////////////////
                                     CONFIG
    //////////////////////////////////////////////////////////////////////////*/

  /**
   * @dev Initializes the module with the given data
   * @param initData The data to initialize the module with
   */
  function init(bytes calldata initData) external {
    _install(initData);
  }

  /**
   * @dev Initializes the module with the given data
   * @param data The data to initialize the module with
   */
  function onInstall(bytes calldata data) external override {
    _install(data);
  }

  function _install(bytes calldata installData) internal {
    // cache the account address
    address account = msg.sender;

    if (isInitialized(account)) revert AlreadyInitialized(account);

    // decode the data to get the tokens and their configurations
    Config memory _config = abi.decode(installData, (Config));

    if (_config.signer == address(0)) {
      revert InvalidSigner(_config.signer);
    }

    config[account].signer = _config.signer;
    config[account].threshold = _config.threshold;

    emit ModuleInitialized(account);
  }

  //it needs the signature and hash as bytes param to validate signature to uninstall
  function disable() external {
    _uninstall();
  }

  /**
   * @dev De-initializes the module
   */
  function onUninstall(bytes calldata) external override {
    _uninstall();
  }

  function _uninstall() internal {
    // cache the account address
    address account = msg.sender;

    // TODO: verify signature

    // clear the configurations
    delete config[account];

    emit ModuleUninitialized(account);
  }

  /**
   * Check if the module is initialized
   * @param smartAccount The smart account to check
   *
   * @return true if the module is initialized, false otherwise
   */
  function isInitialized(address smartAccount) public view returns (bool) {
    return config[smartAccount].signer != address(0);
  }

  /*//////////////////////////////////////////////////////////////////////////
                                     MODULE LOGIC
    //////////////////////////////////////////////////////////////////////////*/

  /**
   * @dev Sets the threshold for the account
   * @param _threshold The new threshold to set
   * @param hookData Additional data for validation
   */
  function setThreshold(uint256 _threshold, bytes calldata hookData) external {
    // cache the account address
    address account = msg.sender;
    // check if the module is initialized and revert if it is not
    if (!isInitialized(account)) revert NotInitialized(account);

    _isValid(hookData);

    config[account].threshold = _threshold;

    emit ThresholdSet(account, _threshold);
  }

  //TODO: change owner function
  //TODO: change config

  /**
   * @dev Retrieves the daily spent amount for an account
   * @param account The account to check
   * @return totalSpentToday The total amount spent today
   */
  function getDailySpent(address account) public view returns (uint256 totalSpentToday) {
    if (!isInitialized(account)) revert NotInitialized(account);

    uint256 today = _getCurrentDay();
    totalSpentToday = dailySpent[account][today];
  }

  /**
   * @dev Retrieves the signer for an account
   * @param account The account to check
   * @return signer The address of the signer
   */
  function getSigner(address account) public view returns (address signer) {
    if (!isInitialized(account)) revert NotInitialized(account);

    signer = config[account].signer;
  }

  /**
   * @dev Retrieves the threshold for an account
   * @param account The account to check
   * @return threshold The current threshold
   */
  function getThreshold(address account) public view returns (uint256 threshold) {
    if (!isInitialized(account)) revert NotInitialized(account);

    threshold = config[account].threshold;
  }

  /**
   * @dev Validates a transaction and updates daily spent amount
   * @param transaction The transaction to validate
   * @param hookData Additional data for validation
   */
  function validationHook(bytes32, Transaction calldata transaction, bytes calldata hookData) external {
    // cache the account address
    address account = msg.sender;
    uint256 amount = Utils.safeCastToU128(transaction.value) + _decodeERC20Amount(transaction.data, transaction.to);

    uint256 today = _getCurrentDay();
    uint256 totalSpentToday = dailySpent[account][today] + amount;
    dailySpent[account][today] = totalSpentToday;

    // Check if the new transaction would exceed the daily threshold
    if (totalSpentToday <= config[account].threshold) {
      return;
    }

    _isValid(hookData);
  }

  function _isValid(bytes calldata hookData) internal view {
    (bytes memory signature, bytes32 signedTxHash) = abi.decode(hookData, (bytes, bytes32));

    // magic = EIP1271_SUCCESS_RETURN_VALUE;

    if (signature.length != 65) {
      // Signature is invalid anyway, but we need to proceed with the signature verification as usual
      // in order for the fee estimation to work correctly
      signature = new bytes(65);

      // Making sure that the signatures look like a valid ECDSA signature and are not rejected rightaway
      // while skipping the main verification process.
      signature[64] = bytes1(uint8(27));
    }

    // extract ECDSA signature
    uint8 v;
    bytes32 r;
    bytes32 s;
    // Signature loading code
    // we jump 32 (0x20) as the first slot of bytes contains the length
    // we jump 65 (0x41) per signature
    // for v we load 32 bytes ending with v (the first 31 come from s) then apply a mask
    assembly {
      r := mload(add(signature, 0x20))
      s := mload(add(signature, 0x40))
      v := and(mload(add(signature, 0x41)), 0xff)
    }

    if (v != 27 && v != 28) {
      revert InvalidSignature();
    }

    // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature
    // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines
    // the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most
    // signatures from current libraries generate a unique signature with an s-value in the lower half order.
    //
    // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value
    // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or
    // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept
    // these malleable signatures as well.
    if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
      revert InvalidSignature();
    }

    address recoveredAddress = ecrecover(signedTxHash, v, r, s);

    // Note, that we should abstain from using the require here in order to allow for fee estimation to work
    if (recoveredAddress != getSigner(msg.sender)) {
      revert InvalidSignature();
    }
  }

  function _getCurrentDay() internal view returns (uint256) {
    return block.timestamp / 1 days;
  }

  function _updateDailySpent(address account, uint256 amount) internal {
    uint256 today = _getCurrentDay();
    dailySpent[account][today] += amount;
  }

  function _decodeERC20Amount(bytes calldata callData, uint256 _token) internal view returns (uint256 amountOut) {
    amountOut = 0;
    // Check if the data is long enough to contain a function selector
    if (callData.length < 4) {
      return amountOut;
    }

    uint256 amountInToken;
    // get the function selector
    bytes4 selector = bytes4(callData[:4]);
    // get the parameters
    bytes calldata params = callData[4:];

    if (selector == IERC20.transfer.selector) {
      // decode the erc20 transfer receiver
      (, amountInToken) = abi.decode(params, (address, uint256));
    } else {
      return amountOut;
    }

    // get pool on syncswap
    // get ETH amountOut
    address token = address(uint160(_token));
    address pool = IPoolFactory(poolFactory).getPool(token, WETH);
    amountOut = IPool(pool).getAmountOut(token, amountInToken, msg.sender);
  }

  /*//////////////////////////////////////////////////////////////////////////
                                     METADATA
    //////////////////////////////////////////////////////////////////////////*/

  /**
   * The name of the module
   *
   * @return name The name of the module
   */
  function name() external pure returns (string memory) {
    return "2FAValidatorModule";
  }

  /**
   * The version of the module
   *
   * @return version The version of the module
   */
  function version() external pure returns (string memory) {
    return "0.0.1";
  }

  /**
   * The version of the module
   *
   * @param interfaceId the interfaceId to check
   *
   * @return true if The module supports interface
   */
  function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
    return interfaceId == type(IValidationHook).interfaceId || interfaceId == type(IERC165).interfaceId;
  }

  /**
   * Check if the module is of a certain type
   *
   * @param typeID The type ID to check
   *
   * @return true if the module is of the given type, false otherwise
   */
  function isModuleType(uint256 typeID) external pure override returns (bool) {
    return typeID == MODULE_TYPE_VALIDATOR;
  }
}

test/2FAValidator.tests.ts

import { assert, expect } from "chai";
import { randomBytes } from "crypto";
import { AbiCoder, Contract, ethers, parseEther } from "ethers";
import { it } from "mocha";
import { encodePasskeyModuleParameters } from "zksync-account/utils";
import { EIP712Signer, utils, Wallet } from "zksync-ethers";

import { deployFactory, getProvider, getWallet, LOCAL_RICH_WALLETS } from "./utils";
import { deployContract, ethersResponse, ethersStaticSalt, prepareTx } from "./utils/transaction";

const provider = getProvider();
const abiCoder = new AbiCoder();

let richWallet: Wallet;
let mockSigner: ethers.HDNodeWallet;

let factory: Contract;
let implementation: Contract;
let passkeyValidator: Contract;
let twofaValidator: Contract;
let account: Contract;

beforeEach(async () => {
  richWallet = getWallet(LOCAL_RICH_WALLETS[0].privateKey);

  mockSigner = Wallet.createRandom(getProvider());

  implementation = await deployContract("ERC7579Account", richWallet, ethersStaticSalt);
  await deployContract("AccountProxy", richWallet, ethersStaticSalt, [await implementation.getAddress()]);
  passkeyValidator = await deployContract("WebAuthValidator", richWallet, ethersStaticSalt);
  factory = await deployFactory("AAFactory", richWallet);
  twofaValidator = await deployContract("TwoFAValidatorModule", richWallet, ethersStaticSalt);

  const passKeyModuleData = encodePasskeyModuleParameters({
    passkeyPublicKey: await ethersResponse.getXyPublicKeys(),
    expectedOrigin: ethersResponse.expectedOrigin,
  });

  const webauthModuleData = abiCoder.encode(
    ["address", "bytes"],
    [await passkeyValidator.getAddress(), passKeyModuleData]);

  const proxyAccountTX = await factory.deployProxy7579Account(randomBytes(32),
    await implementation.getAddress(),
    "ProxyAccount",
    [webauthModuleData], []);

  const proxyAccountTxReceipt = await proxyAccountTX.wait();
  account = new Contract(proxyAccountTxReceipt.contractAddress, implementation.interface, richWallet);
  //   logInfo(`"7579Account" was successfully deployed to ${await account.getAddress()}`);

  await (
    await richWallet.sendTransaction({
      to: await account.getAddress(),
      value: parseEther("10"),
    })
  ).wait();
});
describe("2FA validation module", function () {
  it("should have correct state after deployment", async function () {
    expect(await provider.getBalance(await account.getAddress())).to.eq(
      parseEther("10"),
    );
    const expectedModules: Array<ethers.BytesLike> = [];
    const expectedValidationHooks: Array<ethers.BytesLike> = [];
    const expectedHooks: Array<ethers.BytesLike> = [];

    expect(await account.listModules()).to.deep.eq(expectedModules);
    expect(await account.listHooks(false)).to.deep.eq(expectedHooks);
    expect(await account.listHooks(true)).to.deep.eq(expectedValidationHooks);
  });
  it("should add 2fa validator Hook to account", async function () {
    const initData = abiCoder.encode(
      ["tuple(address,uint256)"], // Solidity equivalent: Config
      [[await richWallet.getAddress(),
        parseEther("1"),
      ]])
    ;
    const hookAndData = ethers.concat([
      await twofaValidator.getAddress(), initData]);

    const callData = account.interface.encodeFunctionData("addHook", [hookAndData, true]);

    const tx = { to: await account.getAddress(), data: callData };

    const addHook = await prepareTx(provider, account, tx, await passkeyValidator.getAddress());
    const txReceipt = await provider.broadcastTransaction(
      utils.serializeEip712(addHook),
    );

    await txReceipt.wait();

    expect(await account.listHooks(true)).to.deep.eq([await twofaValidator.getAddress()]);
  });
  it ("should transfer ETH without 2FA below threshold", async function () {
    const initData = abiCoder.encode(
      ["tuple(address,uint256)"], // Solidity equivalent: Config
      [[await richWallet.getAddress(),
        parseEther("1"),
      ]])
    ;
    const hookAndData = ethers.concat([
      await twofaValidator.getAddress(), initData]);

    const callData = account.interface.encodeFunctionData("addHook", [hookAndData, true]);

    const tx = { to: await account.getAddress(), data: callData };

    const addHook = await prepareTx(provider, account, tx, await passkeyValidator.getAddress());
    const txReceipt = await provider.broadcastTransaction(
      utils.serializeEip712(addHook),
    );

    await txReceipt.wait();

    const receiverBalanceBefore = await provider.getBalance(
      await richWallet.getAddress(),
    );
    const transferValue = parseEther("0.01");

    const tx2 = { to: await richWallet.getAddress(), data: "0x", value: transferValue };

    const transfer = await prepareTx(provider, account, tx2, await passkeyValidator.getAddress(), mockSigner);
    const txReceipt2 = await provider.broadcastTransaction(
      utils.serializeEip712(transfer),
    );

    await txReceipt2.wait();

    assert.equal(receiverBalanceBefore + transferValue, await provider.getBalance(
      await richWallet.getAddress()));
  });
  it("should transfer ETH with 2FA above threshold", async function () {
    const initData = abiCoder.encode(
      ["tuple(address,uint256)"], // Solidity equivalent: Config
      [[await richWallet.getAddress(),
        parseEther("1"),
      ]])
    ;
    const hookAndData = ethers.concat([
      await twofaValidator.getAddress(), initData]);

    const callData = account.interface.encodeFunctionData("addHook", [hookAndData, true]);

    const tx = { to: await account.getAddress(), data: callData };

    const addHook = await prepareTx(provider, account, tx, await passkeyValidator.getAddress());
    const txReceipt = await provider.broadcastTransaction(
      utils.serializeEip712(addHook),
    );

    await txReceipt.wait();

    const receiverBalanceBefore = await provider.getBalance(
      await richWallet.getAddress(),
    );
    const transferValue = parseEther("2");

    const tx2 = { to: await richWallet.getAddress(), data: "0x", value: transferValue };

    const transfer = await prepareTx(provider, account, tx2, await passkeyValidator.getAddress(), richWallet);
    const txReceipt2 = await provider.broadcastTransaction(
      utils.serializeEip712(transfer),
    );

    await txReceipt2.wait();

    assert.equal(receiverBalanceBefore + transferValue, await provider.getBalance(
      await richWallet.getAddress()));
  });
  it("should revert on transfer ETH without 2FA above threshold", async function () {
    const initData = abiCoder.encode(
      ["tuple(address,uint256)"], // Solidity equivalent: Config
      [[await richWallet.getAddress(),
        parseEther("1"),
      ]])
    ;
    const hookAndData = ethers.concat([
      await twofaValidator.getAddress(), initData]);

    const callData = account.interface.encodeFunctionData("addHook", [hookAndData, true]);

    const tx = { to: await account.getAddress(), data: callData };

    const addHook = await prepareTx(provider, account, tx, await passkeyValidator.getAddress());
    const txReceipt = await provider.broadcastTransaction(
      utils.serializeEip712(addHook),
    );

    await txReceipt.wait();

    const transferValue = parseEther("2");

    const tx2 = { to: await richWallet.getAddress(), data: "0x", value: transferValue };

    const transfer = await prepareTx(provider, account, tx2, await passkeyValidator.getAddress(), mockSigner);
    const txReceipt2 = provider.broadcastTransaction(
      utils.serializeEip712(transfer),
    );

    await expect(txReceipt2).to.be.rejected;
  });
  it("should change threshold with 2FA", async function () {
    const initData = abiCoder.encode(
      ["tuple(address,uint256)"], // Solidity equivalent: Config
      [[await richWallet.getAddress(),
        parseEther("1"),
      ]])
    ;
    const hookAndData = ethers.concat([
      await twofaValidator.getAddress(), initData]);

    const callData = account.interface.encodeFunctionData("addHook", [hookAndData, true]);

    const tx = { to: await account.getAddress(), data: callData };

    const addHook = await prepareTx(provider, account, tx, await passkeyValidator.getAddress());
    const txReceipt = await provider.broadcastTransaction(
      utils.serializeEip712(addHook),
    );

    await txReceipt.wait();

    const newThreshold = parseEther("1");
    const tx2 = { to: await twofaValidator.getAddress(), from: await account.getAddress(),
      nonce: await provider.getTransactionCount(await account.getAddress()),
      gasPrice: await provider.getGasPrice(),
      chainId: (await provider.getNetwork()).chainId };

    const signedTxHash = EIP712Signer.getSignedDigest(tx2);
    const signature = ethers.Signature.from(richWallet.signingKey.sign(signedTxHash)).serialized;

    const hookData = ethers.AbiCoder.defaultAbiCoder().encode(
      ["bytes", "bytes32"],
      [signature, signedTxHash],
    );

    const callData2 = twofaValidator.interface.encodeFunctionData("setThreshold", [
      newThreshold, hookData]);
    tx2["data"] = callData2;
    const transfer = await prepareTx(provider, account, tx2, await passkeyValidator.getAddress(), richWallet);

    const txReceipt2 = await provider.broadcastTransaction(
      utils.serializeEip712(transfer),
    );

    await txReceipt2.wait();

    expect(await twofaValidator.getThreshold(await account.getAddress())).to.eq(newThreshold);
  });
  it("should revert on change threshold without 2FA", async function () {
    const initData = abiCoder.encode(
      ["tuple(address,uint256)"], // Solidity equivalent: Config
      [[await richWallet.getAddress(),
        parseEther("1"),
      ]])
    ;
    const hookAndData = ethers.concat([
      await twofaValidator.getAddress(), initData]);

    const callData = account.interface.encodeFunctionData("addHook", [hookAndData, true]);

    const tx = { to: await account.getAddress(), data: callData };

    const addHook = await prepareTx(provider, account, tx, await passkeyValidator.getAddress());
    const txReceipt = await provider.broadcastTransaction(
      utils.serializeEip712(addHook),
    );

    await txReceipt.wait();

    const newThreshold = parseEther("1");
    const tx2 = { to: await twofaValidator.getAddress(), from: await account.getAddress(),
      nonce: await provider.getTransactionCount(await account.getAddress()),
      gasPrice: await provider.getGasPrice(),
      chainId: (await provider.getNetwork()).chainId };

    const signedTxHash = EIP712Signer.getSignedDigest(tx2);
    const signature = ethers.Signature.from(mockSigner.signingKey.sign(signedTxHash)).serialized;

    const hookData = ethers.AbiCoder.defaultAbiCoder().encode(
      ["bytes", "bytes32"],
      [signature, signedTxHash],
    );

    const callData2 = twofaValidator.interface.encodeFunctionData("setThreshold", [
      newThreshold, hookData]);
    tx2["data"] = callData2;
    const transfer = await prepareTx(provider, account, tx2, await passkeyValidator.getAddress(), richWallet);
    const txReceipt2 = provider.broadcastTransaction(
      utils.serializeEip712(transfer),
    );

    await expect(txReceipt2).to.be.reverted;
  });
  it.skip("should transfer twice with 2FA on 2nd transfer above threshold", async function () {});
  it.skip("should revert on 2nd transfer above threshold above 2FA", async function () {});
});

test/utils/transaction.ts

import { Deployer } from "@matterlabs/hardhat-zksync-deploy";
import { AbiCoder, ethers, parseEther } from "ethers";
import * as hre from "hardhat";
import type { Contract, Provider, types, Wallet } from "zksync-ethers";
import { EIP712Signer, utils } from "zksync-ethers";

import { RecordedResponse } from "../utils";

export async function prepareTx(
  provider: Provider,
  account: Contract,
  tx: types.TransactionLike,
  validatorAddress: string,
  signer?: Wallet | ethers.HDNodeWallet,
  paymasterParams?: types.PaymasterParams,
): Promise<types.TransactionLike> {
  if (tx.value == undefined) {
    tx.value = parseEther("0");
  }

  tx = {
    ...tx,
    from: await account.getAddress(),
    nonce: await provider.getTransactionCount(await account.getAddress()),
    gasPrice: await provider.getGasPrice(),
    gasLimit: 30_000_000,
    chainId: (await provider.getNetwork()).chainId,
    type: 113,
    customData: {
      gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
      paymasterParams: paymasterParams,
    } as types.Eip712Meta,
  };

  const signedTxHash = EIP712Signer.getSignedDigest(tx);

  const abiCoder = ethers.AbiCoder.defaultAbiCoder();
  const PasskeySignature = abiCoder.encode(["bytes", "bytes", "bytes32[2]"], [
    ethersResponse.authDataBuffer,
    ethersResponse.clientDataBuffer,
    [ethersResponse.rs.r, ethersResponse.rs.s],
  ]);

  let hookData: string[];
  if (signer) {
    const ecdsaSignature = ethers.concat([ethers.Signature.from(signer.signingKey.sign(signedTxHash)).serialized]);

    hookData = [
      AbiCoder.defaultAbiCoder().encode(["bytes", "bytes32"], [ecdsaSignature, signedTxHash]),
    ];
  } else {
    hookData = [];
  }

  const signature = abiCoder.encode(
    ["bytes", "address", "bytes[]"],
    [PasskeySignature, validatorAddress, hookData],
  );

  tx.customData = {
    ...tx.customData,
    customSignature: signature,
  };

  return tx;
}

export const ethersStaticSalt = new Uint8Array([
  205, 241, 161, 186, 101, 105, 79,
  248, 98, 64, 50, 124, 168, 204,
  200, 71, 214, 169, 195, 118, 199,
  62, 140, 111, 128, 47, 32, 21,
  177, 177, 174, 166,
]);

export const ethersResponse = new RecordedResponse("test/signed-challenge.json");

export const deployContract = async (contractName: string, wallet: Wallet, salt: ethers.BytesLike, constructorArguments?: Array<unknown>): Promise<ethers.Contract> => {
  const deployer = new Deployer(hre, wallet);
  const contractArtifact = await deployer
    .loadArtifact(contractName);

  const contract = await deployer.deploy(contractArtifact, constructorArguments);
  // const address = await contract.getAddress();

  // logInfo(`"${contractArtifact.contractName}" was successfully deployed to ${address}`);

  return contract;
};