diff --git a/.github/workflows/test-suite-e2e-tests.yml b/.github/workflows/test-suite-e2e-tests.yml index f989d2161a..0ba0f9aa49 100644 --- a/.github/workflows/test-suite-e2e-tests.yml +++ b/.github/workflows/test-suite-e2e-tests.yml @@ -190,6 +190,11 @@ jobs: run: | ./fhevm-cli test user-decryption + - name: Delegated User Decryption test + working-directory: test-suite/fhevm + run: | + ./fhevm-cli test delegated-user-decryption + - name: ERC20 test working-directory: test-suite/fhevm run: | @@ -205,11 +210,6 @@ jobs: run: | ./fhevm-cli test public-decrypt-http-mixed - - name: Delegate User Decryption (partial test) - working-directory: test-suite/fhevm - run: | - ./fhevm-cli test delegate-user-decryption - - name: Random operators test (subset) working-directory: test-suite/fhevm run: | diff --git a/.gitignore b/.gitignore index 2c9d32aef1..bcdf5c2cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ logs/ .env.* !.env.example !.env.sample +# Allow shared env templates in subprojects +!test-suite/e2e/.env.devnet # If you have .env files specific to subprojects, e.g. subproject/.env, # the .env rule above will catch them. diff --git a/package-lock.json b/package-lock.json index 462b96d01a..a52614c9a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25607,7 +25607,7 @@ "dependencies": { "@fhevm/solidity": "*", "@openzeppelin/contracts": "^5.3.0", - "@zama-fhe/relayer-sdk": "0.4.0-4", + "@zama-fhe/relayer-sdk": "^0.4.0-5", "bigint-buffer": "^1.1.5", "dotenv": "^16.0.3", "encrypted-types": "^0.0.4" @@ -27423,9 +27423,9 @@ } }, "test-suite/e2e/node_modules/@zama-fhe/relayer-sdk": { - "version": "0.4.0-4", - "resolved": "https://registry.npmjs.org/@zama-fhe/relayer-sdk/-/relayer-sdk-0.4.0-4.tgz", - "integrity": "sha512-F4B4QQesRcYeUzpLSyZ+P5x9y2ALwkVL/xTSt3nsFknp33YpyDkV9BhGNz4qt8C5XQh15OvwbYyVPXuv7cggDA==", + "version": "0.4.0-5", + "resolved": "https://registry.npmjs.org/@zama-fhe/relayer-sdk/-/relayer-sdk-0.4.0-5.tgz", + "integrity": "sha512-UraJ8r2rPB6INfRFXXxSgBvkSG4FSuJ3pG0bOAFHN1LOdRiOy0P1tvi9UVbeuOITXUJishAVt+4ft3xxRXt+QA==", "license": "BSD-3-Clause-Clear", "dependencies": { "commander": "^14.0.0", diff --git a/test-suite/e2e/.env.devnet b/test-suite/e2e/.env.devnet new file mode 100644 index 0000000000..eaa5f97bc9 --- /dev/null +++ b/test-suite/e2e/.env.devnet @@ -0,0 +1,31 @@ +# +# CONTRACT ADDRESS OVERRIDE CONFIGURATION +# +# If the runtime Hardhat network selected via the --network flag (e.g., 'devnet') +# matches the value defined here, all contract addresses and URLs below +# will OVERRIDE any default or previously loaded configuration. +FHEVM_HARDHAT_NETWORK="devnet" + +# Gateway +INPUT_VERIFICATION_ADDRESS="0xf091D9B4C2da7ecd11858cDD1F4515a8a767D755" +DECRYPTION_ADDRESS="0xA4dc265D54D25D41565c60d36097E8955B03decD" +# Host +ACL_CONTRACT_ADDRESS="0xBCA6F8De823a399Dc431930FD5EE550Bf1C0013e" +KMS_VERIFIER_CONTRACT_ADDRESS="0x3F3819BeBE4bD0EFEf8078Df6f9B574ADa80CCA4" +INPUT_VERIFIER_CONTRACT_ADDRESS="0x6B32f47E39B0F8bE8bEAD5B8990F62E3e28ac08d" +FHEVM_EXECUTOR_CONTRACT_ADDRESS="0x5cc8c5A366E733d4f1e677B2A9C08Bc2ea49b302" +HCU_LIMIT_CONTRACT_ADDRESS="0xb8E70273De41498aAF047fd73ae1F3025D8709f8" +RELAYER_URL="https://relayer.dev.zama.cloud" + +# Hardhat account seed (used for hardhat/anvil/sepolia) +MNEMONIC="test test test test test test test test test test test junk" + +# Provider keys +INFURA_API_KEY="" +ETHERSCAN_API_KEY="" + +# Required: authorized caller addresses for Numeric deploy (comma-separated list) +AUTHORIZED_CALLER_ADDRESSES="" + +# Optional: set a specific payment token for SimplifiedAuction deploys +SIMPLIFIED_AUCTION_PAYMENT_TOKEN="" diff --git a/test-suite/e2e/.env.example b/test-suite/e2e/.env.example index d25c278c80..70d9de7f67 100644 --- a/test-suite/e2e/.env.example +++ b/test-suite/e2e/.env.example @@ -1,4 +1,9 @@ MNEMONIC="adapt mosquito move limb mobile illegal tree voyage juice mosquito burger raise father hope layer" +RPC_URL="" +SEPOLIA_ETH_RPC_URL="" +MAINNET_ETH_RPC_URL="" +RELAYER_URL="" +ZAMA_FHEVM_API_KEY="" # Gateway CHAIN_ID_GATEWAY="54321" CHAIN_ID_HOST="12345" @@ -9,5 +14,13 @@ ACL_CONTRACT_ADDRESS="0x05fD9B5EFE0a996095f42Ed7e77c390810CF660c" KMS_VERIFIER_CONTRACT_ADDRESS="0xcCAe95fF1d11656358E782570dF0418F59fA40e1" INPUT_VERIFIER_CONTRACT_ADDRESS="0xa1880e99d86F081E8D3868A8C4732C8f65dfdB11" FHEVM_EXECUTOR_CONTRACT_ADDRESS="0x12B064FB845C1cc05e9493856a1D637a73e944bE" +TEST_INPUT_CONTRACT_ADDRESS="" HARDHAT_NETWORK="staging" + +# Smoke runner (optional) +BETTERSTACK_HEARTBEAT_URL="" +# SMOKE_DEPLOY_CONTRACT=1 +# SMOKE_RUN_TESTS=1 +# SMOKE_TX_TIMEOUT_SECS=48 +# SMOKE_DECRYPT_TIMEOUT_SECS=120 diff --git a/test-suite/e2e/README.md b/test-suite/e2e/README.md index 17f8ed190d..f1784ededb 100644 --- a/test-suite/e2e/README.md +++ b/test-suite/e2e/README.md @@ -11,3 +11,77 @@ REPORT_GAS=true npx hardhat test npx hardhat node npx hardhat ignition deploy ./ignition/modules/Lock.ts ``` + +## Smoke runner (inputFlow) + +Runs a single on-chain smoke flow (input + add42 + decrypt) using Hardhat as a runtime +and hardened transaction handling. + +### Prereqs + +**For sepolia/mainnet**, most config is auto-populated from the SDK (`SepoliaConfig`/`MainnetConfig`). +You only need: + +- `RPC_URL` (or `SEPOLIA_ETH_RPC_URL` / `MAINNET_ETH_RPC_URL`) +- `MNEMONIC` +- `ZAMA_FHEVM_API_KEY` (mainnet only) + +**For devnet**, use the pre-configured `.env.devnet` (all addresses included): + +```shell +DOTENV_CONFIG_PATH=./.env.devnet npx hardhat run --network devnet scripts/smoke-inputflow.ts +``` + +**For other networks** (staging, custom), set all variables manually - see `.env.example`. + +Network-specific RPC URLs: + +- staging/zwsDev: `RPC_URL` (defaults to localhost:8545) +- sepolia: `SEPOLIA_ETH_RPC_URL` (falls back to `RPC_URL`) +- mainnet: `MAINNET_ETH_RPC_URL` (falls back to `RPC_URL`) + +For pod deployments, just set `RPC_URL` - it works for all networks. + +Set `TEST_INPUT_CONTRACT_ADDRESS` to reuse an existing contract (requires `SMOKE_DEPLOY_CONTRACT=0`). + +Hardhat loads env from `test-suite/e2e/.env` by default; override with `DOTENV_CONFIG_PATH`. +You can also store secrets with Hardhat vars, e.g. `npx hardhat vars set SEPOLIA_ETH_RPC_URL` (it will prompt for the value). +For devnet, `test-suite/e2e/.env.devnet` provides a ready baseline (use `DOTENV_CONFIG_PATH=./.env.devnet`). + +### Signer configuration + +The smoke runner uses HD wallet signers derived from `MNEMONIC`. By default, it uses indices `0,1,2` +for automatic failover - if one signer has a stuck transaction, it falls back to another. + +**Important:** All configured signers should be funded for maximum resilience. The script logs all +available signers at startup with their balances and warns if any have low balance (< 0.1 ETH). + +To derive signer addresses from a mnemonic (for funding): +```shell +# Using Foundry's cast +cast wallet address --mnemonic "your mnemonic here" --mnemonic-index 0 +cast wallet address --mnemonic "your mnemonic here" --mnemonic-index 1 +cast wallet address --mnemonic "your mnemonic here" --mnemonic-index 2 +``` + +### Smoke-specific knobs (defaults in parentheses) + +- `SMOKE_SIGNER_INDICES` (`0,1,2`) - comma-separated list of signer indices to use for failover +- `SMOKE_TX_TIMEOUT_SECS` (`48`) +- `SMOKE_TX_MAX_RETRIES` (`2`) +- `SMOKE_FEE_BUMP` (`1.125^4`) +- `SMOKE_MAX_BACKLOG` (`3`) +- `SMOKE_CANCEL_BACKLOG` (`1`) - set to `0` to disable auto-cancel of pending transactions +- `SMOKE_DEPLOY_CONTRACT` (`1`) - set to `0` to attach to existing contract via `TEST_INPUT_CONTRACT_ADDRESS` +- `SMOKE_RUN_TESTS` (`1`) - set to `0` to deploy contract only without running tests +- `SMOKE_DECRYPT_TIMEOUT_SECS` (`120`) - timeout for decryption operations +- `BETTERSTACK_HEARTBEAT_URL` (optional) - if set, pings BetterStack on success; reports error with exit code on failure + +### Run + +```shell +cd test-suite/e2e +npx hardhat run --network zwsDev scripts/smoke-inputflow.ts +npx hardhat run --network sepolia scripts/smoke-inputflow.ts +npx hardhat run --network mainnet scripts/smoke-inputflow.ts +``` diff --git a/test-suite/e2e/contracts/DelegateUserDecryptDelegate.sol b/test-suite/e2e/contracts/DelegateUserDecryptDelegate.sol deleted file mode 100644 index dfa3143e25..0000000000 --- a/test-suite/e2e/contracts/DelegateUserDecryptDelegate.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause-Clear - -pragma solidity ^0.8.24; - -import "@fhevm/solidity/lib/FHE.sol"; -import {E2ECoprocessorConfig} from "./E2ECoprocessorConfigLocal.sol"; - - -/// @notice Contract that simulates the "delegate" account for a user decryption with delegation demonstration. -contract DelegateUserDecryptDelegate is E2ECoprocessorConfig { -} diff --git a/test-suite/e2e/contracts/DelegateUserDecryptDelegator.sol b/test-suite/e2e/contracts/DelegateUserDecryptDelegator.sol deleted file mode 100644 index 6f77fea27e..0000000000 --- a/test-suite/e2e/contracts/DelegateUserDecryptDelegator.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause-Clear - -pragma solidity ^0.8.24; - -import "@fhevm/solidity/lib/FHE.sol"; -import {E2ECoprocessorConfig} from "./E2ECoprocessorConfigLocal.sol"; - - -/// @notice Contract that simulates the "delegator" account for a user decryption with delegation demonstration. -contract DelegateUserDecryptDelegator is E2ECoprocessorConfig { - /// @dev Encrypted boolean - ebool public xBool; - - /// @notice Constructor to initialize encrypted values and set permissions - constructor() { - xBool = FHE.asEbool(true); - FHE.allowThis(xBool); - FHE.allow(xBool, msg.sender); - } - - function delegate(address contract_delegate_address) public { - FHE.delegateUserDecryption(msg.sender, contract_delegate_address, uint64(block.timestamp + 1 days)); - } - - function revoke(address contract_delegate_address) public { - FHE.revokeUserDecryptionDelegation(msg.sender, contract_delegate_address); - } -} diff --git a/test-suite/e2e/contracts/E2ECoprocessorConfigLocal.sol b/test-suite/e2e/contracts/E2ECoprocessorConfigLocal.sol index 4908acc9dc..5a04bc21ee 100644 --- a/test-suite/e2e/contracts/E2ECoprocessorConfigLocal.sol +++ b/test-suite/e2e/contracts/E2ECoprocessorConfigLocal.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.24; import {CoprocessorConfig, FHE} from "@fhevm/solidity/lib/FHE.sol"; library DefaultCoprocessorConfig { + /// @dev These addresses are placeholders. They are patched at runtime via sed + /// in the E2E test runner script with the actual deployment addresses. function getConfig() internal pure returns (CoprocessorConfig memory) { return CoprocessorConfig({ diff --git a/test-suite/e2e/contracts/SmartWalletWithDelegation.sol b/test-suite/e2e/contracts/SmartWalletWithDelegation.sol new file mode 100644 index 0000000000..911ae86d6e --- /dev/null +++ b/test-suite/e2e/contracts/SmartWalletWithDelegation.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.24; + +import "@fhevm/solidity/lib/FHE.sol"; +import {E2ECoprocessorConfig} from "./E2ECoprocessorConfigLocal.sol"; + +/// @notice SmartWallet contract that supports delegated user decryption. +contract SmartWalletWithDelegation is E2ECoprocessorConfig { + struct Transaction { + address target; + bytes data; + } + + event ProposedTx(uint256 indexed txId, address target, bytes data); + + uint256 public txCounter; + address public owner; + mapping(uint256 => Transaction) public transactions; + mapping(uint256 => bool) public executed; + + constructor(address _owner) { + require(_owner != address(0), "Owner cannot be zero address"); + owner = _owner; + } + + modifier onlyOwner() { + require(msg.sender == owner, "Sender is not the owner"); + _; + } + + /// @notice Propose a transaction and assume as approved (since there's only one owner). + /// @param target The target contract address + /// @param data The calldata to execute + function proposeTx(address target, bytes calldata data) external onlyOwner returns (uint256) { + txCounter++; + uint256 txId = txCounter; + transactions[txCounter] = Transaction({target: target, data: data}); + emit ProposedTx(txId, target, data); + return txId; + } + + /// @notice Execute a previously proposed transaction. + /// @param txId The transaction ID to execute. + function executeTx(uint256 txId) external onlyOwner { + require(txId != 0 && txId <= txCounter, "Invalid txId"); + require(!executed[txId], "tx has already been executed"); + Transaction memory transaction = transactions[txId]; + + (bool success, ) = (transaction.target).call(transaction.data); + require(success, "tx reverted"); + executed[txId] = true; + } + + /// @notice Delegate user decryption for a specific contract. + /// @dev This allows an EOA to decrypt confidential data owned by this smart wallet. + /// @param delegate The address that will be able to user decrypt. + /// @param delegateContractAddress The contract address for which delegation applies. + /// @param expirationTimestamp When the delegation expires. + function delegateUserDecryption( + address delegate, + address delegateContractAddress, + uint64 expirationTimestamp + ) external onlyOwner { + FHE.delegateUserDecryption(delegate, delegateContractAddress, expirationTimestamp); + } + + /// @notice Revoke a previously granted delegation. + /// @param delegate The address to revoke delegation from. + /// @param delegateContractAddress The contract address for which to revoke delegation. + function revokeUserDecryptionDelegation(address delegate, address delegateContractAddress) external onlyOwner { + FHE.revokeUserDecryptionDelegation(delegate, delegateContractAddress); + } +} diff --git a/test-suite/e2e/contracts/smoke/SmokeTestInput.sol b/test-suite/e2e/contracts/smoke/SmokeTestInput.sol new file mode 100644 index 0000000000..8d63be8886 --- /dev/null +++ b/test-suite/e2e/contracts/smoke/SmokeTestInput.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.24; + +import "@fhevm/solidity/lib/FHE.sol"; +import {CoprocessorConfig} from "@fhevm/solidity/lib/Impl.sol"; + +/// @notice Smoke test contract with constructor-based coprocessor config injection. +/// @dev Unlike TestInput.sol which uses E2ECoprocessorConfig (sed-patched at runtime), +/// this contract receives addresses at deploy time, enabling smoke tests to run on +/// devnet, sepolia, and mainnet without sed patching. +contract SmokeTestInput { + euint64 public resUint64; + + constructor(address aclAddress, address coprocessorAddress, address kmsVerifierAddress) { + FHE.setCoprocessor( + CoprocessorConfig({ + ACLAddress: aclAddress, + CoprocessorAddress: coprocessorAddress, + KMSVerifierAddress: kmsVerifierAddress + }) + ); + } + + function requestUint64NonTrivial(externalEuint64 inputHandle, bytes calldata inputProof) public { + resUint64 = FHE.fromExternal(inputHandle, inputProof); + FHE.allowThis(resUint64); + } + + // Adds a trivially-encrypted 42 to the user-provided encrypted uint64 input. + function add42ToInput64(externalEuint64 inputHandle, bytes calldata inputProof) public { + euint64 input = FHE.fromExternal(inputHandle, inputProof); + euint64 trivial42 = FHE.asEuint64(42); + resUint64 = FHE.add(input, trivial42); + FHE.allowThis(resUint64); + FHE.allow(resUint64, msg.sender); + FHE.makePubliclyDecryptable(resUint64); + } +} diff --git a/test-suite/e2e/contracts/TestInput.sol b/test-suite/e2e/contracts/smoke/TestInput.sol similarity index 92% rename from test-suite/e2e/contracts/TestInput.sol rename to test-suite/e2e/contracts/smoke/TestInput.sol index 87ed73ce39..fafcdba77c 100644 --- a/test-suite/e2e/contracts/TestInput.sol +++ b/test-suite/e2e/contracts/smoke/TestInput.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import "@fhevm/solidity/lib/FHE.sol"; -import {E2ECoprocessorConfig} from "./E2ECoprocessorConfigLocal.sol"; +import {E2ECoprocessorConfig} from "../E2ECoprocessorConfigLocal.sol"; contract TestInput is E2ECoprocessorConfig { euint64 public resUint64; diff --git a/test-suite/e2e/hardhat.config.ts b/test-suite/e2e/hardhat.config.ts index 1391ac3a8e..a8768e95fe 100644 --- a/test-suite/e2e/hardhat.config.ts +++ b/test-suite/e2e/hardhat.config.ts @@ -1,11 +1,12 @@ import '@nomicfoundation/hardhat-toolbox'; import dotenv from 'dotenv'; import type { HardhatUserConfig, extendProvider } from 'hardhat/config'; -import { task } from 'hardhat/config'; +import { task, vars } from 'hardhat/config'; import type { NetworkUserConfig } from 'hardhat/types'; import { resolve } from 'path'; const NUM_ACCOUNTS = 120; +const DEFAULT_NETWORK = 'staging'; task('compile:specific', 'Compiles only the specified contract') .addParam('contract', "The contract's path") @@ -19,11 +20,9 @@ task('compile:specific', 'Compiles only the specified contract') const dotenvConfigPath: string = process.env.DOTENV_CONFIG_PATH || './.env'; dotenv.config({ path: resolve(__dirname, dotenvConfigPath) }); -// Ensure that we have all the environment variables we need. -let mnemonic: string | undefined = process.env.MNEMONIC; -if (!mnemonic) { - mnemonic = 'adapt mosquito move limb mobile illegal tree voyage juice mosquito burger raise father hope layer'; // default mnemonic in case it is undefined (needed to avoid panicking when deploying on real network) -} +const defaultMnemonic = + 'adapt mosquito move limb mobile illegal tree voyage juice mosquito burger raise father hope layer'; +const mnemonic: string = process.env.MNEMONIC ?? vars.get('MNEMONIC', defaultMnemonic); task('coverage').setAction(async (taskArgs, hre, runSuper) => { hre.config.networks.hardhat.allowUnlimitedContractSize = true; @@ -90,38 +89,39 @@ const chainIds = { function getChainConfig(chain: keyof typeof chainIds): NetworkUserConfig { let jsonRpcUrl: string; const defaultRpcUrl = 'http://localhost:8545'; + const requestedNetwork = (() => { + const idx = process.argv.indexOf('--network'); + if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1].startsWith('-')) { + return process.argv[idx + 1]; + } + return process.env.HARDHAT_NETWORK; + })(); + const shouldWarn = requestedNetwork ? requestedNetwork === chain : chain === DEFAULT_NETWORK; switch (chain) { case 'staging': - jsonRpcUrl = process.env.RPC_URL || defaultRpcUrl; - if (jsonRpcUrl === defaultRpcUrl && !process.env.RPC_URL) { - console.warn( - `WARN: RPC_URL environment variable not set for network '${chain}'. Using default: ${defaultRpcUrl}`, - ); - } - break; case 'zwsDev': - jsonRpcUrl = process.env.RPC_URL || defaultRpcUrl; - if (jsonRpcUrl === defaultRpcUrl && !process.env.RPC_URL) { - console.warn( - `WARN: RPC_URL environment variable not set for network '${chain}'. Using default: ${defaultRpcUrl}`, - ); + jsonRpcUrl = process.env.RPC_URL ?? vars.get('RPC_URL', defaultRpcUrl); + if (shouldWarn && jsonRpcUrl === defaultRpcUrl) { + console.warn(`WARN: RPC_URL not set for network '${chain}'. Using default: ${defaultRpcUrl}`); } break; case 'sepolia': - jsonRpcUrl = process.env.RPC_URL || defaultRpcUrl; - if (jsonRpcUrl === defaultRpcUrl && !process.env.RPC_URL) { - console.warn( - `WARN: RPC_URL environment variable not set for network '${chain}'. Using default: ${defaultRpcUrl}`, - ); + jsonRpcUrl = process.env.SEPOLIA_ETH_RPC_URL || vars.get('SEPOLIA_ETH_RPC_URL', '') || process.env.RPC_URL; + if (!jsonRpcUrl) { + if (shouldWarn) { + throw new Error('SEPOLIA_ETH_RPC_URL (or RPC_URL) is required for sepolia network'); + } + jsonRpcUrl = 'https://rpc.sepolia.org'; // placeholder for config validation } break; case 'mainnet': - jsonRpcUrl = process.env.RPC_URL || defaultRpcUrl; - if (jsonRpcUrl === defaultRpcUrl && !process.env.RPC_URL) { - console.warn( - `WARN: RPC_URL environment variable not set for network '${chain}'. Using default: ${defaultRpcUrl}`, - ); + jsonRpcUrl = process.env.MAINNET_ETH_RPC_URL || vars.get('MAINNET_ETH_RPC_URL', '') || process.env.RPC_URL; + if (!jsonRpcUrl) { + if (shouldWarn) { + throw new Error('MAINNET_ETH_RPC_URL (or RPC_URL) is required for mainnet network'); + } + jsonRpcUrl = 'https://eth.llamarpc.com'; // placeholder for config validation } break; case 'localCoprocessor': @@ -162,7 +162,7 @@ function getChainConfig(chain: keyof typeof chainIds): NetworkUserConfig { const config: HardhatUserConfig = { // workaround a hardhat bug with --parallel --network // https://github.com/NomicFoundation/hardhat/issues/2756 - defaultNetwork: 'staging', + defaultNetwork: DEFAULT_NETWORK, mocha: { timeout: 300000, }, diff --git a/test-suite/e2e/package.json b/test-suite/e2e/package.json index eed8d8448d..8175d82890 100644 --- a/test-suite/e2e/package.json +++ b/test-suite/e2e/package.json @@ -16,9 +16,9 @@ "dependencies": { "@fhevm/solidity": "*", "@openzeppelin/contracts": "^5.3.0", - "@zama-fhe/relayer-sdk": "0.4.0-4", + "@zama-fhe/relayer-sdk": "^0.4.0-5", "bigint-buffer": "^1.1.5", "dotenv": "^16.0.3", "encrypted-types": "^0.0.4" } -} \ No newline at end of file +} diff --git a/test-suite/e2e/scripts/smoke-inputflow.ts b/test-suite/e2e/scripts/smoke-inputflow.ts new file mode 100644 index 0000000000..29d23941b9 --- /dev/null +++ b/test-suite/e2e/scripts/smoke-inputflow.ts @@ -0,0 +1,524 @@ +import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; +import type { Provider, TransactionReceipt, TransactionResponse } from 'ethers'; +import { ethers, network } from 'hardhat'; +import assert from 'node:assert/strict'; + +import { aclAddress, coprocessorAddress, createInstance, kmsVerifierAddress } from '../test/instance'; +import { userDecryptSingleHandle } from '../test/utils'; +import type { SmokeTestInput } from '../types'; + +type FeeData = { + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +}; + +type TxOverrides = { + gasLimit?: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + nonce: number; +}; + +type SignerState = { + signer: HardhatEthersSigner; + index: number; + address: string; + latest: number; + pending: number; + balance: bigint; +}; + +const DEFAULT_SIGNER_INDICES = '0,1,2'; +const DEFAULT_TX_MAX_RETRIES = 2; +const DEFAULT_MAX_BACKLOG = 3; +const TIME_PER_BLOCK = 12; +const NUMBER_OF_BLOCKS = 4; +const DEFAULT_TX_TIMEOUT_SECS = TIME_PER_BLOCK * NUMBER_OF_BLOCKS; +const DEFAULT_FEE_BUMP = 1.125 ** NUMBER_OF_BLOCKS; +const DEFAULT_DECRYPT_TIMEOUT_SECS = 120; + +const MIN_PRIORITY_FEE = ethers.parseUnits('2', 'gwei'); +const CANCEL_GAS_LIMIT = 21_000n; +const LOW_BALANCE_THRESHOLD = ethers.parseEther('0.1'); +const SMOKE_GAS_ESTIMATE = 1_000_000n; // ~1M gas covers deploy + call with buffer + +const withTimeout = (promise: Promise, timeoutMs: number, label: string): Promise => { + let timer: NodeJS.Timeout; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms: ${label}`)), timeoutMs); + }); + return Promise.race([ + promise.then((result) => { + clearTimeout(timer); + return result; + }), + timeoutPromise, + ]); +}; + +const parseIndices = (value: string): number[] => { + const indices = value + .split(',') + .map((entry) => Number.parseInt(entry.trim(), 10)) + .filter((entry) => Number.isFinite(entry) && entry >= 0); + if (indices.length === 0) { + throw new Error(`SMOKE_SIGNER_INDICES resolved to no valid indices (value: "${value}")`); + } + return indices; +}; + +const parseBooleanEnv = (name: string, defaultValue: boolean): boolean => { + const value = process.env[name]; + if (value === undefined) return defaultValue; + if (value === '1') return true; + if (value === '0') return false; + throw new Error(`${name} must be '0' or '1' (got: "${value}")`); +}; + +const formatSignerState = (state: SignerState): string => { + const balanceStr = ethers.formatEther(state.balance); + const lowBalanceWarning = state.balance < LOW_BALANCE_THRESHOLD ? ' ⚠️ LOW_BALANCE' : ''; + return `index=${state.index} address=${state.address} latest=${state.latest} pending=${state.pending} balance=${balanceStr} ETH${lowBalanceWarning}`; +}; + +const bumpFees = (base: FeeData, multiplier: number): FeeData => { + const scaled = BigInt(Math.round(multiplier * 100)); + let maxFeePerGas = (base.maxFeePerGas * scaled) / 100n; + let maxPriorityFeePerGas = (base.maxPriorityFeePerGas * scaled) / 100n; + if (maxFeePerGas < maxPriorityFeePerGas) { + maxFeePerGas = maxPriorityFeePerGas; + } + return { maxFeePerGas, maxPriorityFeePerGas }; +}; + +const getBaseFees = async (provider: Provider): Promise => { + const feeData = await provider.getFeeData(); + const priority = feeData.maxPriorityFeePerGas ?? MIN_PRIORITY_FEE; + const maxPriorityFeePerGas = priority > MIN_PRIORITY_FEE ? priority : MIN_PRIORITY_FEE; + let maxFeePerGas = feeData.maxFeePerGas ?? null; + + if (maxFeePerGas == null) { + const pendingBlock = await provider.getBlock('pending'); + const baseFee = pendingBlock?.baseFeePerGas ?? feeData.gasPrice ?? MIN_PRIORITY_FEE; + maxFeePerGas = baseFee * 2n + maxPriorityFeePerGas; + } + + if (maxFeePerGas < maxPriorityFeePerGas) { + maxFeePerGas = maxPriorityFeePerGas; + } + + return { maxFeePerGas, maxPriorityFeePerGas }; +}; + +const sendWithRetries = async (params: { + signer: HardhatEthersSigner; + label: string; + nonce: number; + timeoutMs: number; + maxRetries: number; + feeBump: number; + send: (overrides: TxOverrides) => Promise; +}): Promise => { + const { signer, label, nonce, timeoutMs, maxRetries, feeBump, send } = params; + const provider = signer.provider; + if (!provider) throw new Error('Signer has no provider'); + + const baseFees = await getBaseFees(provider); + const sentTxHashes: string[] = []; + + // Check if any previously sent tx was mined (used after send failure and as final fallback) + const findMinedReceipt = async (): Promise => { + for (const hash of sentTxHashes) { + let receipt: TransactionReceipt | null; + try { + receipt = await provider.getTransactionReceipt(hash); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn(`SMOKE_TX_RECEIPT_CHECK_FAILED label=${label} hash=${hash} error=${msg}`); + continue; + } + if (receipt) { + console.log(`SMOKE_TX_LATE_RECEIPT label=${label} hash=${hash}`); + if (receipt.status !== 1) throw new Error(`Transaction reverted (label=${label}, hash=${hash})`); + return receipt; + } + } + return null; + }; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const fees = bumpFees(baseFees, Math.pow(feeBump, attempt)); + + // 1. Try to send (may fail if nonce was consumed by a previous tx) + let tx: TransactionResponse; + try { + tx = await send({ nonce, ...fees }); + sentTxHashes.push(tx.hash); + console.log(`SMOKE_TX_SENT label=${label} nonce=${nonce} hash=${tx.hash} attempt=${attempt}`); + } catch (error) { + const mined = await findMinedReceipt(); + if (mined) return mined; + const msg = error instanceof Error ? error.message : String(error); + console.warn(`SMOKE_TX_SEND_FAILED label=${label} nonce=${nonce} attempt=${attempt} error=${msg}`); + continue; + } + + // 2. Wait for confirmation + // ethers v6 tx.wait() behavior: + // - Success: returns receipt with status === 1 + // - Revert: throws CALL_EXCEPTION (receipt available on error) + // - Timeout: throws TIMEOUT + // - Replacement mined: throws TRANSACTION_REPLACED + try { + const receipt = await tx.wait(1, timeoutMs); + // Success - tx mined and not reverted + if (receipt?.status === 1) return receipt; + // Defensive: if somehow we get a receipt with status !== 1, treat as revert + throw new Error(`Transaction reverted (label=${label}, hash=${tx.hash})`); + } catch (error: unknown) { + const err = error as { code?: string; reason?: string; receipt?: TransactionReceipt }; + + // CALL_EXCEPTION: tx was mined but reverted - terminal error, don't retry + if (err.code === 'CALL_EXCEPTION') { + const receiptHash = err.receipt?.hash ?? tx.hash; + throw new Error(`Transaction reverted (label=${label}, hash=${receiptHash})`); + } + + // TIMEOUT: tx.wait() timed out - retry with bumped fees + if (err.code === 'TIMEOUT') { + console.warn(`SMOKE_TX_TIMEOUT label=${label} nonce=${nonce} attempt=${attempt} hash=${tx.hash}`); + continue; + } + + // TRANSACTION_REPLACED: another tx with same nonce was mined + // The `reason` field indicates what happened: + // - "repriced": same tx data, higher fees (our retry got mined) → SUCCESS + // - "cancelled": 0-value self-transfer (nonce was cancelled) → NOT our tx + // - "replaced": completely different tx (another process used nonce) → NOT our tx + // In our controlled smoke test, only "repriced" should occur. We defensively + // reject other reasons to avoid misreporting success for unrelated transactions. + if (err.code === 'TRANSACTION_REPLACED') { + const reason = err.reason; + const receiptHash = err.receipt?.hash ?? 'unknown'; + if (reason === 'repriced' && err.receipt) { + console.log(`SMOKE_TX_REPRICED label=${label} original=${tx.hash} mined=${receiptHash}`); + if (err.receipt.status === 1) return err.receipt; + throw new Error(`Repriced transaction reverted (label=${label}, hash=${receiptHash})`); + } + // Cancelled or replaced by unrelated tx - log warning and retry + console.warn( + `SMOKE_TX_REPLACED_UNEXPECTED label=${label} original=${tx.hash} reason=${reason} mined=${receiptHash}`, + ); + continue; + } + + // Unknown error - log and retry (network issues, etc.) + const msg = error instanceof Error ? error.message : String(error); + console.warn(`SMOKE_TX_WAIT_ERROR label=${label} nonce=${nonce} attempt=${attempt} error=${msg}`); + continue; + } + } + + // 3. Final fallback: a tx may have been mined after our last wait timed out + const mined = await findMinedReceipt(); + if (mined) return mined; + + throw new Error(`All ${maxRetries + 1} attempts failed (label=${label})`); +}; + +const getSignerStates = async ( + provider: Provider, + signers: HardhatEthersSigner[], + indices: number[], +): Promise => { + return Promise.all( + indices.map(async (index) => { + const signer = signers[index]; + if (!signer) { + throw new Error(`Signer index ${index} is unavailable; only ${signers.length} signers loaded.`); + } + const address = signer.address; + const [latest, pending, balance] = await Promise.all([ + provider.getTransactionCount(address, 'latest'), + provider.getTransactionCount(address, 'pending'), + provider.getBalance(address), + ]); + return { signer, index, address, latest, pending, balance }; + }), + ); +}; + +const cancelBacklog = async (params: { + signer: HardhatEthersSigner; + latest: number; + pending: number; + timeoutMs: number; + maxRetries: number; + feeBump: number; +}): Promise => { + const { signer, latest, pending, timeoutMs, maxRetries, feeBump } = params; + + for (let nonce = latest; nonce < pending; nonce += 1) { + await sendWithRetries({ + signer, + label: `cancel-nonce-${nonce}`, + nonce, + timeoutMs, + maxRetries, + feeBump, + send: (overrides) => + signer.sendTransaction({ + to: signer.address, + value: 0n, + gasLimit: CANCEL_GAS_LIMIT, + ...overrides, + }), + }); + } +}; + +async function runSmoke(): Promise { + const provider = ethers.provider; + const signerIndices = parseIndices(process.env.SMOKE_SIGNER_INDICES ?? DEFAULT_SIGNER_INDICES); + const timeoutMs = (Number(process.env.SMOKE_TX_TIMEOUT_SECS) || DEFAULT_TX_TIMEOUT_SECS) * 1000; + const maxRetries = Number(process.env.SMOKE_TX_MAX_RETRIES) || DEFAULT_TX_MAX_RETRIES; + const feeBump = Number(process.env.SMOKE_FEE_BUMP) || DEFAULT_FEE_BUMP; + const maxBacklog = Number(process.env.SMOKE_MAX_BACKLOG) || DEFAULT_MAX_BACKLOG; + const decryptTimeoutMs = (Number(process.env.SMOKE_DECRYPT_TIMEOUT_SECS) || DEFAULT_DECRYPT_TIMEOUT_SECS) * 1000; + const allowCancel = parseBooleanEnv('SMOKE_CANCEL_BACKLOG', true); + const deployContract = parseBooleanEnv('SMOKE_DEPLOY_CONTRACT', true); + const runTests = parseBooleanEnv('SMOKE_RUN_TESTS', true); + const existingContractAddress = process.env.TEST_INPUT_CONTRACT_ADDRESS; + + const allSigners = await ethers.getSigners(); + const states = await getSignerStates(provider, allSigners, signerIndices); + + // Calculate minimum usable balance based on current gas prices + const baseFees = await getBaseFees(provider); + const minUsableBalance = baseFees.maxFeePerGas * SMOKE_GAS_ESTIMATE; + + console.log(`SMOKE_START network=${network.name} chainId=${(await provider.getNetwork()).chainId}`); + console.log(`SMOKE_SIGNERS_AVAILABLE count=${states.length} minBalance=${ethers.formatEther(minUsableBalance)} ETH`); + states.forEach((state) => { + console.log(` SMOKE_SIGNER ${formatSignerState(state)}`); + }); + + let selected = states.find((state) => state.pending === state.latest && state.balance >= minUsableBalance); + + if (!selected) { + if (!allowCancel) { + throw new Error('All signers have pending backlogs and auto-cancel is disabled.'); + } + const primary = states[0]; + if (!primary) { + throw new Error('No signer candidates available.'); + } + const backlog = primary.pending - primary.latest; + if (backlog <= 0) { + throw new Error('No clean signer available; primary has no backlog to cancel.'); + } + if (backlog > maxBacklog) { + throw new Error(`Signer backlog too large (backlog=${backlog}, max=${maxBacklog}).`); + } + const minCancelBalance = baseFees.maxFeePerGas * CANCEL_GAS_LIMIT * BigInt(backlog); + if (primary.balance < minCancelBalance) { + throw new Error( + `Signer ${primary.address} has insufficient balance for cancel (need ${ethers.formatEther(minCancelBalance)} ETH, have ${ethers.formatEther(primary.balance)} ETH)`, + ); + } + + console.log(`SMOKE_CANCEL backlog=${backlog} signer=${primary.address}`); + await cancelBacklog({ + signer: primary.signer, + latest: primary.latest, + pending: primary.pending, + timeoutMs, + maxRetries, + feeBump, + }); + + const [latest, pending] = await Promise.all([ + provider.getTransactionCount(primary.address, 'latest'), + provider.getTransactionCount(primary.address, 'pending'), + ]); + if (pending !== latest) { + throw new Error('Backlog remains after auto-cancel.'); + } + selected = { ...primary, latest, pending }; + } + + console.log(`SMOKE_SIGNER_SELECTED ${formatSignerState(selected)}`); + + const signer = selected.signer; + const signerAddress = signer.address; + const contractFactory = await ethers.getContractFactory('SmokeTestInput', signer); + + let contractAddress: string; + let contract: SmokeTestInput; + let deployMs = 0; + + if (deployContract) { + if (existingContractAddress) { + console.log('SMOKE_DEPLOY_CONTRACT ignoring TEST_INPUT_CONTRACT_ADDRESS'); + } + console.log( + `SMOKE_COPROCESSOR_CONFIG acl=${aclAddress} coprocessor=${coprocessorAddress} kms=${kmsVerifierAddress}`, + ); + const deployStart = Date.now(); + const deployTx = await contractFactory.getDeployTransaction(aclAddress, coprocessorAddress, kmsVerifierAddress); + const deployNonce = await provider.getTransactionCount(signerAddress, 'pending'); + let gasEstimate: bigint; + try { + gasEstimate = await provider.estimateGas({ ...deployTx, from: signerAddress }); + } catch (err) { + console.error('SMOKE_GAS_ESTIMATE_FAILED operation=deploy', err); + throw new Error(`Gas estimation failed for deploy: ${err instanceof Error ? err.message : String(err)}`); + } + const gasLimit = (gasEstimate * 120n) / 100n; + + const receipt = await sendWithRetries({ + signer, + label: 'deploy-SmokeTestInput', + nonce: deployNonce, + timeoutMs, + maxRetries, + feeBump, + send: (overrides) => + signer.sendTransaction({ + ...deployTx, + ...overrides, + gasLimit, + }), + }); + if (receipt.status !== 1 || !receipt.contractAddress) { + throw new Error('Deployment failed or no contract address in receipt'); + } + deployMs = Date.now() - deployStart; + + contractAddress = receipt.contractAddress; + contract = contractFactory.attach(contractAddress) as SmokeTestInput; + console.log(`SMOKE_DEPLOYED contractAddress=${contractAddress}`); + } else if (existingContractAddress) { + contractAddress = existingContractAddress; + contract = contractFactory.attach(contractAddress) as SmokeTestInput; + console.log(`SMOKE_ATTACH contractAddress=${contractAddress}`); + } else { + throw new Error('TEST_INPUT_CONTRACT_ADDRESS is required when SMOKE_DEPLOY_CONTRACT=0.'); + } + + let timingReport = deployMs > 0 ? `deploy=${deployMs}ms ` : ''; + + if (!runTests) { + console.log(`SMOKE_DEPLOY_ONLY contract=${contractAddress} ${timingReport.trim()}`); + } else { + const encryptStart = Date.now(); + const instance = await createInstance(); + const input = instance.createEncryptedInput(contractAddress, signerAddress); + input.add64(7n); + const encryptedInput = await input.encrypt(); + const encryptMs = Date.now() - encryptStart; + + const callNonce = await provider.getTransactionCount(signerAddress, 'pending'); + let callGasEstimate: bigint; + try { + callGasEstimate = await contract.add42ToInput64.estimateGas(encryptedInput.handles[0], encryptedInput.inputProof); + } catch (err) { + console.error('SMOKE_GAS_ESTIMATE_FAILED operation=add42ToInput64', err); + throw new Error(`Gas estimation failed for add42ToInput64: ${err instanceof Error ? err.message : String(err)}`); + } + const gasLimit = (callGasEstimate * 120n) / 100n; + + const txStart = Date.now(); + const receipt = await sendWithRetries({ + signer, + label: 'add42ToInput64', + nonce: callNonce, + timeoutMs, + maxRetries, + feeBump, + send: (overrides) => + contract.add42ToInput64(encryptedInput.handles[0], encryptedInput.inputProof, { + ...overrides, + gasLimit, + }), + }); + const txMs = Date.now() - txStart; + + assert.equal(receipt.status, 1, 'on-chain call failed'); + + const handle = await contract.resUint64(); + const { publicKey, privateKey } = instance.generateKeypair(); + + const decryptStart = Date.now(); + const decryptedValue = await withTimeout( + userDecryptSingleHandle(handle, contractAddress, instance, signer, privateKey, publicKey), + decryptTimeoutMs, + 'userDecryptSingleHandle', + ); + assert.equal(decryptedValue, 49n); + + const res = await withTimeout(instance.publicDecrypt([handle]), decryptTimeoutMs, 'publicDecrypt'); + assert.deepEqual(res.clearValues, { [handle]: 49n }); + const decryptMs = Date.now() - decryptStart; + + timingReport += `encrypt=${encryptMs}ms tx=${txMs}ms decrypt=${decryptMs}ms`; + console.log(`SMOKE_SUCCESS signer=${signerAddress} contract=${contractAddress} ${timingReport.trim()}`); + } + + // Heartbeat ping on success + const heartbeatUrl = process.env.BETTERSTACK_HEARTBEAT_URL; + if (heartbeatUrl) { + await fetch(heartbeatUrl) + .then(() => console.log('SMOKE_HEARTBEAT_SENT')) + .catch((err) => console.warn(`SMOKE_HEARTBEAT_FAILED ${err.message}`)); + } + + // Post-success cleanup: clear backlogs on any unclean signers + // Re-fetch state since nonces may have changed during the test + if (allowCancel) { + const freshStates = await getSignerStates(provider, allSigners, signerIndices); + for (const state of freshStates) { + const backlog = state.pending - state.latest; + if (backlog <= 0) continue; + + const minBalanceForCancel = baseFees.maxFeePerGas * CANCEL_GAS_LIMIT * BigInt(backlog); + if (state.balance < minBalanceForCancel) { + console.warn( + `SMOKE_CLEANUP_SKIPPED signer=${state.address} reason=low_balance need=${ethers.formatEther(minBalanceForCancel)} have=${ethers.formatEther(state.balance)}`, + ); + continue; + } + + if (backlog > maxBacklog) { + console.warn( + `SMOKE_CLEANUP_SKIPPED signer=${state.address} reason=backlog_too_large backlog=${backlog} max=${maxBacklog}`, + ); + continue; + } + + console.log(`SMOKE_CLEANUP signer=${state.address} backlog=${backlog}`); + await cancelBacklog({ + signer: state.signer, + latest: state.latest, + pending: state.pending, + timeoutMs, + maxRetries, + feeBump, + }); + } + } +} + +runSmoke().catch(async (error) => { + const errorMessage = String(error); + console.error(`SMOKE_FAILED ${errorMessage}`); + process.exitCode = 1; + + const heartbeatUrl = process.env.BETTERSTACK_HEARTBEAT_URL; + if (heartbeatUrl) { + await fetch(`${heartbeatUrl}/1`, { + method: 'POST', + body: errorMessage.slice(0, 10000), + }).catch((err) => console.warn(`SMOKE_HEARTBEAT_FAILED ${err.message}`)); + } +}); diff --git a/test-suite/e2e/test/delegateUserDecrypt/delegateUserDecryption.ts b/test-suite/e2e/test/delegateUserDecrypt/delegateUserDecryption.ts deleted file mode 100644 index 2356550f09..0000000000 --- a/test-suite/e2e/test/delegateUserDecrypt/delegateUserDecryption.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect } from 'chai'; -import { ethers } from 'hardhat'; - -import { createInstances } from '../instance'; -import { getSigners, initSigners } from '../signers'; -import { userDecryptSingleHandle } from '../utils'; - -describe('Delegate user decryption', function () { - before(async function () { - await initSigners(2); - this.signers = await getSigners(); - this.instances = await createInstances(this.signers); - const contractFactory = await ethers.getContractFactory('DelegateUserDecryptDelegator'); - - this.contract = await contractFactory.connect(this.signers.alice).deploy(); - await this.contract.waitForDeployment(); - this.contractAddress = await this.contract.getAddress(); - - const contractFactoryDelegate = await ethers.getContractFactory('DelegateUserDecryptDelegate'); - - this.contractDelegate = await contractFactoryDelegate.connect(this.signers.alice).deploy(); - await this.contractDelegate.waitForDeployment(); - this.contractDelegateAddress = await this.contractDelegate.getAddress(); - - this.instances = await createInstances(this.signers); - }); - - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - it('test delegation and revocation propagation', async function () { - const block_time = 1000; // ms - - const delegate = await this.contract.delegate(this.contractDelegateAddress); - const delegate_result = await delegate.wait(1); - expect(delegate_result.status).to.equal(1); - - await sleep( 15 * block_time); // wait for 15 seconds to ensure delegation is active - - const revoke = await this.contract.revoke(this.contractDelegateAddress); - const revoke_result = await revoke.wait(1); - expect(revoke_result.status).to.equal(1); - await sleep( 15 * block_time); // wait for 15 seconds to ensure delegation is revoked - - }); -}); diff --git a/test-suite/e2e/test/delegatedUserDecryption/delegatedUserDecryption.ts b/test-suite/e2e/test/delegatedUserDecryption/delegatedUserDecryption.ts new file mode 100644 index 0000000000..6481174a02 --- /dev/null +++ b/test-suite/e2e/test/delegatedUserDecryption/delegatedUserDecryption.ts @@ -0,0 +1,251 @@ +import { expect } from 'chai'; +import { ethers } from 'hardhat'; + +import { createInstances } from '../instance'; +import { getSigners, initSigners } from '../signers'; +import { delegatedUserDecryptSingleHandle, waitForBlock } from '../utils'; + +const USER_DECRYPTION_NOT_DELEGATED_SELECTOR = '0x0190c506'; + +describe('Delegated user decryption', function () { + before(async function () { + await initSigners(3); + this.signers = await getSigners(); + this.instances = await createInstances(this.signers); + + // Deploy the EncryptedERC20 token contract. + const tokenFactory = await ethers.getContractFactory('EncryptedERC20'); + this.token = await tokenFactory.connect(this.signers.alice).deploy('Zama Confidential Token', 'ZAMA'); + await this.token.waitForDeployment(); + this.tokenAddress = await this.token.getAddress(); + + // Deploy SmartWalletWithDelegation with Bob as the owner. + const smartWalletFactory = await ethers.getContractFactory('SmartWalletWithDelegation'); + this.smartWallet = await smartWalletFactory.connect(this.signers.bob).deploy(this.signers.bob.address); + await this.smartWallet.waitForDeployment(); + this.smartWalletAddress = await this.smartWallet.getAddress(); + + // Alice mints tokens to herself. + const mintAmount = 1000000n; + const mintTx = await this.token.connect(this.signers.alice).mint(mintAmount); + await mintTx.wait(); + + // Alice transfers some tokens to the smartWallet contract. + const transferAmount = 500000n; + const input = this.instances.alice.createEncryptedInput(this.tokenAddress, this.signers.alice.address); + input.add64(transferAmount); + const encryptedTransferAmount = await input.encrypt(); + + const transferTx = await this.token + .connect(this.signers.alice) + ['transfer(address,bytes32,bytes)']( + this.smartWalletAddress, + encryptedTransferAmount.handles[0], + encryptedTransferAmount.inputProof, + ); + await transferTx.wait(); + }); + + it('test delegated user decryption - smartWallet owner delegates his own EOA to decrypt the smartWallet balance', async function () { + // Bob (smartWallet owner) delegates decryption rights to his own EOA. + const expirationTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now + const delegateTx = await this.smartWallet + .connect(this.signers.bob) + .delegateUserDecryption( + this.signers.bob.address, + this.tokenAddress, + expirationTimestamp, + ); + await delegateTx.wait(); + + // Wait for 15 blocks to ensure delegation is propagated by the coprocessor. + const currentBlock = await ethers.provider.getBlockNumber(); + await waitForBlock(currentBlock + 15); + + // Get the encrypted balance handle of the smartWallet. + const balanceHandle = await this.token.balanceOf(this.smartWalletAddress); + + // Bob's EOA can now decrypt the smartWallet's confidential balance. + const { publicKey, privateKey } = this.instances.bob.generateKeypair(); + + const decryptedBalance = await delegatedUserDecryptSingleHandle( + this.instances.bob, + balanceHandle, + this.tokenAddress, + this.smartWalletAddress, + this.signers.bob.address, + this.signers.bob, + privateKey, + publicKey, + ); + + // Verify the decrypted balance matches what was transferred. + expect(decryptedBalance).to.equal(500000n); + }); + + it('test delegated user decryption - smartWallet owner delegates a third EOA to decrypt the smartWallet balance', async function () { + // Bob (smartWallet owner) delegates decryption rights to Carol's EOA. + const expirationTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now + const delegateTx = await this.smartWallet + .connect(this.signers.bob) + .delegateUserDecryption( + this.signers.carol.address, + this.tokenAddress, + expirationTimestamp, + ); + await delegateTx.wait(); + + // Wait for 15 blocks to ensure delegation is propagated by the coprocessor. + const currentBlock = await ethers.provider.getBlockNumber(); + await waitForBlock(currentBlock + 15); + + // Get the encrypted balance handle of the smartWallet. + const balanceHandle = await this.token.balanceOf(this.smartWalletAddress); + + // Carol's EOA can now decrypt the smartWallet's confidential balance. + const { publicKey, privateKey } = this.instances.carol.generateKeypair(); + + const decryptedBalance = await delegatedUserDecryptSingleHandle( + this.instances.carol, + balanceHandle, + this.tokenAddress, + this.smartWalletAddress, + this.signers.carol.address, + this.signers.carol, + privateKey, + publicKey, + ); + + // Verify the decrypted balance matches what was transferred. + expect(decryptedBalance).to.equal(500000n); + }); + + it('test delegated user decryption - smartWallet can execute transference of funds to a third EOA', async function () { + // First, Bob needs to delegate so the smartWallet can initiate transfers. + const expirationTimestamp = Math.floor(Date.now() / 1000) + 86400; + const delegateTx = await this.smartWallet + .connect(this.signers.bob) + .delegateUserDecryption( + this.signers.bob.address, + this.tokenAddress, + expirationTimestamp, + ); + await delegateTx.wait(); + + // Wait for 15 blocks to ensure delegation is propagated by the coprocessor. + let currentBlock = await ethers.provider.getBlockNumber(); + await waitForBlock(currentBlock + 15); + + // Get the current smartWallet balance before transfer + const smartWalletBalanceBefore = await this.token.balanceOf(this.smartWalletAddress); + const { publicKey: pkBefore, privateKey: skBefore } = this.instances.bob.generateKeypair(); + const decryptedBalanceBefore = await delegatedUserDecryptSingleHandle( + this.instances.bob, + smartWalletBalanceBefore, + this.tokenAddress, + this.smartWalletAddress, + this.signers.bob.address, + this.signers.bob, + skBefore, + pkBefore, + ); + + // Bob proposes a transaction from the smartWallet to transfer tokens to Carol. + // The encrypted input must be created for the smartWallet address since it will be the msg.sender. + const transferAmount = 100000n; + const input = this.instances.bob.createEncryptedInput(this.tokenAddress, this.smartWalletAddress); + input.add64(transferAmount); + const encryptedTransferAmount = await input.encrypt(); + + // Encode the transfer function call with full signature to avoid ambiguity. + const transferData = this.token.interface.encodeFunctionData( + 'transfer(address,bytes32,bytes)', + [ + this.signers.carol.address, + encryptedTransferAmount.handles[0], + encryptedTransferAmount.inputProof, + ] + ); + + // Propose the transaction. + const proposeTx = await this.smartWallet + .connect(this.signers.bob) + .proposeTx(this.tokenAddress, transferData); + await proposeTx.wait(); + + // Get the transaction ID. + const txId = await this.smartWallet.txCounter(); + + // Execute the transaction. + const executeTx = await this.smartWallet + .connect(this.signers.bob) + .executeTx(txId); + await executeTx.wait(); + + // Verify the smartWallet balance decreased. + const smartWalletBalanceAfter = await this.token.balanceOf(this.smartWalletAddress); + const { publicKey: pkAfter, privateKey: skAfter } = this.instances.bob.generateKeypair(); + const decryptedBalanceAfter = await delegatedUserDecryptSingleHandle( + this.instances.bob, + smartWalletBalanceAfter, + this.tokenAddress, + this.smartWalletAddress, + this.signers.bob.address, + this.signers.bob, + skAfter, + pkAfter, + ); + + // The smartWallet balance should have decreased by the transfer amount. + expect(Number(decryptedBalanceBefore) - Number(decryptedBalanceAfter)).to.equal(Number(transferAmount)); + }); + + it('test delegated user decryption - smartWallet revokes the delegation of user decryption to an EOA', async function () { + // First, ensure Bob has delegation. + const expirationTimestamp = Math.floor(Date.now() / 1000) + 86400; + const delegateTx = await this.smartWallet + .connect(this.signers.bob) + .delegateUserDecryption( + this.signers.bob.address, + this.tokenAddress, + expirationTimestamp, + ); + await delegateTx.wait(); + + // Wait for 15 blocks to ensure delegation is propagated by the coprocessor. + const currentBlock1 = await ethers.provider.getBlockNumber(); + await waitForBlock(currentBlock1 + 15); + + // Revoke the delegation for Bob's EOA. + const revokeTx = await this.smartWallet + .connect(this.signers.bob) + .revokeUserDecryptionDelegation( + this.signers.bob.address, + this.tokenAddress, + ); + await revokeTx.wait(); + + // Wait for 15 blocks to ensure revocation is propagated by the coprocessor. + const currentBlock2 = await ethers.provider.getBlockNumber(); + await waitForBlock(currentBlock2 + 15); + + // Try to decrypt the smartWallet balance with Bob's EOA, which should now fail. + const balanceHandle = await this.token.balanceOf(this.smartWalletAddress); + const { publicKey, privateKey } = this.instances.bob.generateKeypair(); + + await expect( + delegatedUserDecryptSingleHandle( + this.instances.bob, + balanceHandle, + this.tokenAddress, + this.smartWalletAddress, + this.signers.bob.address, + this.signers.bob, + privateKey, + publicKey, + ) + ).to.be.rejectedWith( + new RegExp(USER_DECRYPTION_NOT_DELEGATED_SELECTOR), + ); + }); +}); diff --git a/test-suite/e2e/test/encryptedERC20/EncryptedERC20.ts b/test-suite/e2e/test/encryptedERC20/EncryptedERC20.ts index bd674fcb00..17c1854db4 100644 --- a/test-suite/e2e/test/encryptedERC20/EncryptedERC20.ts +++ b/test-suite/e2e/test/encryptedERC20/EncryptedERC20.ts @@ -115,7 +115,7 @@ describe('EncryptedERC20', function () { expect.fail('Expected an error to be thrown - Bob should not be able to user decrypt Alice balance'); } catch (error) { expect(error.message).to.equal( - `User ${this.signers.bob.address} is not authorized to user decrypt handle ${balanceHandleAlice}!`, + `User address ${this.signers.bob.address} is not authorized to user decrypt handle ${balanceHandleAlice}!`, ); } }); diff --git a/test-suite/e2e/test/instance.ts b/test-suite/e2e/test/instance.ts index e560131dd9..09d9933e89 100644 --- a/test-suite/e2e/test/instance.ts +++ b/test-suite/e2e/test/instance.ts @@ -1,20 +1,68 @@ -import { createInstance as createFhevmInstance } from '@zama-fhe/relayer-sdk/node'; +import { MainnetConfig, SepoliaConfig, createInstance as createFhevmInstance } from '@zama-fhe/relayer-sdk/node'; import { network } from 'hardhat'; +import { vars } from 'hardhat/config'; import type { Signers } from './signers'; import { FhevmInstances } from './types'; -const kmsAdd = process.env.KMS_VERIFIER_CONTRACT_ADDRESS; -const aclAdd = process.env.ACL_CONTRACT_ADDRESS; -const inputAdd = process.env.INPUT_VERIFIER_CONTRACT_ADDRESS; -const gatewayChainID = +process.env.CHAIN_ID_GATEWAY!; -const hostChainID = +process.env.CHAIN_ID_HOST!; -const verifyingContractAddressDecryption = process.env.DECRYPTION_ADDRESS!; -const verifyingContractAddressInputVerification = process.env.INPUT_VERIFICATION_ADDRESS!; -const relayerUrl = process.env.RELAYER_URL!; +const defaults = (() => { + const chainId = network.config.chainId; + if (network.name === 'sepolia' || chainId === 11155111) return SepoliaConfig; + if (network.name === 'mainnet' || chainId === 1) return MainnetConfig; + return undefined; +})(); + +const requireEnv = (value: string | undefined, name: string): string => { + if (!value) throw new Error(`${name} is required`); + return value; +}; + +const kmsVerifierAddress = requireEnv( + process.env.KMS_VERIFIER_CONTRACT_ADDRESS || defaults?.kmsContractAddress, + 'KMS_VERIFIER_CONTRACT_ADDRESS', +); + +const aclAddress = requireEnv(process.env.ACL_CONTRACT_ADDRESS || defaults?.aclContractAddress, 'ACL_CONTRACT_ADDRESS'); + +// Coprocessor/executor address defaults (not in SDK, values from ZamaConfig.sol) +const coprocessorDefaults: Record = { + sepolia: '0x92C920834Ec8941d2C77D188936E1f7A6f49c127', + mainnet: '0xD82385dADa1ae3E969447f20A3164F6213100e75', +}; +const coprocessorAddress = requireEnv( + process.env.FHEVM_EXECUTOR_CONTRACT_ADDRESS || coprocessorDefaults[network.name], + 'FHEVM_EXECUTOR_CONTRACT_ADDRESS', +); + +const inputAdd = process.env.INPUT_VERIFIER_CONTRACT_ADDRESS || defaults?.inputVerifierContractAddress; +if (!inputAdd) throw new Error('INPUT_VERIFIER_CONTRACT_ADDRESS is required'); + +const gatewayChainID = Number(process.env.CHAIN_ID_GATEWAY) || defaults?.gatewayChainId; +if (!gatewayChainID) throw new Error('CHAIN_ID_GATEWAY is required'); + +const hostChainID = Number(process.env.CHAIN_ID_HOST) || defaults?.chainId; +if (!hostChainID) throw new Error('CHAIN_ID_HOST is required'); + +const verifyingContractAddressDecryption = + process.env.DECRYPTION_ADDRESS || defaults?.verifyingContractAddressDecryption; +if (!verifyingContractAddressDecryption) throw new Error('DECRYPTION_ADDRESS is required'); + +const verifyingContractAddressInputVerification = + process.env.INPUT_VERIFICATION_ADDRESS || defaults?.verifyingContractAddressInputVerification; +if (!verifyingContractAddressInputVerification) throw new Error('INPUT_VERIFICATION_ADDRESS is required'); + +const relayerUrl = process.env.RELAYER_URL || defaults?.relayerUrl; +if (!relayerUrl) throw new Error('RELAYER_URL is required'); + +// API key is a secret - support hardhat vars for secure storage +const apiKey = process.env.ZAMA_FHEVM_API_KEY ?? vars.get('ZAMA_FHEVM_API_KEY', ''); +const isMainnet = network.name === 'mainnet' || network.config.chainId === 1; +if (isMainnet && !apiKey) { + throw new Error('ZAMA_FHEVM_API_KEY is required for mainnet'); +} +const auth = apiKey ? { __type: 'ApiKeyHeader' as const, value: apiKey } : undefined; export const createInstances = async (accounts: Signers): Promise => { - // Create instance const instances: FhevmInstances = {} as FhevmInstances; await Promise.all( Object.keys(accounts).map(async (k) => { @@ -25,18 +73,19 @@ export const createInstances = async (accounts: Signers): Promise { - console.log('relayer url given to create instance', relayerUrl); - console.log('network', network.name, network.config.url); - const instance = await createFhevmInstance({ - verifyingContractAddressDecryption: verifyingContractAddressDecryption, - verifyingContractAddressInputVerification: verifyingContractAddressInputVerification, - kmsContractAddress: kmsAdd, + return createFhevmInstance({ + verifyingContractAddressDecryption, + verifyingContractAddressInputVerification, + kmsContractAddress: kmsVerifierAddress, inputVerifierContractAddress: inputAdd, - aclContractAddress: aclAdd, + aclContractAddress: aclAddress, network: network.config.url, - relayerUrl: relayerUrl, - gatewayChainId: Number(gatewayChainID), - chainId: Number(hostChainID), + relayerUrl, + gatewayChainId: gatewayChainID, + chainId: hostChainID, + ...(auth ? { auth } : {}), }); - return instance; }; + +// Export coprocessor config addresses for smoke tests +export { aclAddress, coprocessorAddress, kmsVerifierAddress }; diff --git a/test-suite/e2e/test/pausedProtocol/pausedGateway.ts b/test-suite/e2e/test/pausedProtocol/pausedGateway.ts index fa8d29462a..b7e15ec09b 100644 --- a/test-suite/e2e/test/pausedProtocol/pausedGateway.ts +++ b/test-suite/e2e/test/pausedProtocol/pausedGateway.ts @@ -5,6 +5,8 @@ import { createInstances } from '../instance'; import { getSigners, initSigners } from '../signers'; import { userDecryptSingleHandle } from '../utils'; +const ENFORCED_PAUSE_SELECTOR = '0xd93c0665'; + describe('Paused gateway', function () { before(async function () { await initSigners(2); @@ -37,7 +39,7 @@ describe('Paused gateway', function () { ); inputAlice.add64(18446744073709550042n); - await expect(inputAlice.encrypt()).to.be.rejectedWith(new RegExp('Could not estimate gas')); + await expect(inputAlice.encrypt()).to.be.rejectedWith(new RegExp(ENFORCED_PAUSE_SELECTOR)); }); // The following test case should cover the Decryption.userDecryptionRequest method calling. @@ -53,7 +55,7 @@ describe('Paused gateway', function () { privateKey, publicKey, ), - ).to.be.rejectedWith(new RegExp('Could not estimate gas')); + ).to.be.rejectedWith(new RegExp(ENFORCED_PAUSE_SELECTOR)); }); // The following test case should cover the Decryption.publicDecryptionRequest method calling. @@ -62,7 +64,7 @@ describe('Paused gateway', function () { const handleAddress = await this.httpPublicDecryptContract.xAddress(); const handle32 = await this.httpPublicDecryptContract.xUint32(); await expect(this.instances.alice.publicDecrypt([handleAddress, handle32, handleBool])).to.be.rejectedWith( - new RegExp('Could not estimate gas'), + new RegExp(ENFORCED_PAUSE_SELECTOR), ); }); }); diff --git a/test-suite/e2e/test/userDecryption/userDecryption.ts b/test-suite/e2e/test/userDecryption/userDecryption.ts index 84d0bf4593..28138c8d59 100644 --- a/test-suite/e2e/test/userDecryption/userDecryption.ts +++ b/test-suite/e2e/test/userDecryption/userDecryption.ts @@ -45,7 +45,7 @@ describe('User decryption', function () { expect.fail('Expected an error to be thrown - Bob should not be able to user decrypt Alice balance'); } catch (error) { expect(error.message).to.equal( - `User ${this.signers.bob.address} is not authorized to user decrypt handle ${handle}!`, + `User address ${this.signers.bob.address} is not authorized to user decrypt handle ${handle}!`, ); } @@ -87,7 +87,7 @@ describe('User decryption', function () { expect.fail('Expected an error to be thrown - userAddress and contractAddress cannot be equal'); } catch (error) { expect(error.message).to.equal( - `userAddress ${this.signers.alice.address} should not be equal to contractAddress when requesting user decryption!`, + `User address ${this.signers.alice.address} should not be equal to contract address when requesting user decryption!`, ); } }); diff --git a/test-suite/e2e/test/utils.ts b/test-suite/e2e/test/utils.ts index b9d33071eb..cab0743305 100644 --- a/test-suite/e2e/test/utils.ts +++ b/test-suite/e2e/test/utils.ts @@ -144,79 +144,57 @@ export const userDecryptSingleHandle = async ( return decryptedValue; }; -export const delegatedUserDecryptSingleHandle = async (params: { - instance: any; - handle: string; - contractAddress: string; - delegate: Signer; - delegatorAddress: string; - delegateKmsPrivateKey: string; - delegateKmsPublicKey: string; -}): Promise => { - const HandleContractPairs = [ +export const delegatedUserDecryptSingleHandle = async ( + instance: any, + handle: string, + contractAddress: string, + delegatorAddress: string, + delegateAddress: string, + signer: Signer, + delegatePrivateKey: string, + delegatePublicKey: string, +): Promise => { + const handleContractPairs = [ { - handle: params.handle, - contractAddress: params.contractAddress, + handle, + contractAddress, }, ]; - const startTimeStamp = Math.floor(Date.now() / 1000).toString(); - const durationDays = '10'; // String for consistency - const contractAddresses = [params.contractAddress]; - const instance = params.instance; - const delegate = params.delegate; - const delegateAddress = await delegate.getAddress(); + const startTimeStamp = Math.floor(Date.now() / 1000); + const durationDays = 10; + const contractAddresses = [contractAddress]; // The `delegate` creates a EIP712 with the `delegator` address - const eip712 = instance.createEIP712( - params.delegateKmsPublicKey, + const eip712 = instance.createDelegatedUserDecryptEIP712( + delegatePublicKey, contractAddresses, + delegatorAddress, startTimeStamp, durationDays, - // ============================================ - // Todo: uncomment this line when ready! - // - // params.delegatorAddress, - // - // ============================================ ); // Update the signing to match the new primaryType - const delegateSignature = await delegate.signTypedData( + const delegateSignature = await signer.signTypedData( eip712.domain, { - // ============================================ - // Todo: uncomment this line when ready! - // - // DelegatedUserDecryptRequestVerification: eip712.types.DelegatedUserDecryptRequestVerification, - // - // ============================================ - // - // Remove This line when ready! - UserDecryptRequestVerification: eip712.types.UserDecryptRequestVerification, - // - // ============================================ + DelegatedUserDecryptRequestVerification: eip712.types.DelegatedUserDecryptRequestVerification, }, eip712.message, ); - // ======================================================== - // - // Todo: Call the delegate user decrypt function instead! - // - // ======================================================== - const result = await instance.userDecrypt( - HandleContractPairs, - params.delegateKmsPrivateKey, - params.delegateKmsPublicKey, + const result = await instance.delegatedUserDecrypt( + handleContractPairs, + delegatePrivateKey, + delegatePublicKey, delegateSignature.replace('0x', ''), contractAddresses, + delegatorAddress, delegateAddress, startTimeStamp, durationDays, ); - const decryptedValue = result[params.handle]; - return decryptedValue; + return result[handle]; }; const abi = [ diff --git a/test-suite/fhevm/config/relayer/local.yaml b/test-suite/fhevm/config/relayer/local.yaml index b5f6eca112..e380fca872 100644 --- a/test-suite/fhevm/config/relayer/local.yaml +++ b/test-suite/fhevm/config/relayer/local.yaml @@ -1,24 +1,54 @@ gateway: blockchain_rpc: - ws_url: "ws://localhost:8757" - http_url: "http://localhost:8757" - chain_id: 654321 + read_http_url: "http://gateway-node:8546" + chain_id: 54321 ws_health_check_timeout_secs: 5 http_health_check_timeout_secs: 5 - listener: - # Optional: set the starting block number for event subscriptions + # Unified listener pool configuration + # Supports multiple listener types with shared deduplication and staggered recycling + listener_pool: + # Optional: set the starting block number for all listeners # last_block_number: null - ws_reconnect_config: + # Reconnection configuration for WebSocket connection failures + reconnect_config: max_attempts: 20 retry_interval_ms: 500 - # Number of parallel listener instances (1-3) - listener_instances: 3 + # Max consecutive poll failures before giving up (polling listeners only) + # Higher than reconnect_config.max_attempts to tolerate transient errors (503, 429) + polling_max_attempts: 40 + # Connection recycle interval in minutes + # Staggered across all listeners to avoid simultaneous reconnections + recycle_interval_mins: 30 + # Polling interval in milliseconds (for polling type listeners) + poll_interval_ms: 2000 # TTL for event deduplication cache in seconds (1-10) dedup_ttl_seconds: 5 + # Maximum capacity for deduplication cache + # + # Sizing formula: events_per_second * num_listeners * dedup_ttl_seconds * 1.2 (safety buffer) + # + # Recommended values (with 3 listeners, 5s TTL, 1.2x buffer): + # 100 events/sec → 1,800 + # 300 events/sec → 5,400 + # 1000 events/sec → 18,000 + # 5000 events/sec → 90,000 + # + # Current setting: 100,000 (suitable for ~5500 events/sec) dedup_max_capacity: 100000 + # List of listeners in the pool + # Each listener has a type (subscription or polling) and URL + # Instance ID is assigned by position (0-indexed) for stagger calculation + # In production, use different URLs for redundancy + listeners: + - type: subscription + url: "ws://gateway-node:8546" + - type: subscription + url: "ws://gateway-node:8546" + - type: subscription + url: "ws://gateway-node:8546" tx_engine: - private_key: GATEWAY_PRIVATE_KEY + private_key: 0xcb97ef45d352446a6adf810cf8f63c73ada027160c271da9bb8cfcb3d944d257 max_concurrency: 100 retry: max_attempts: 100 @@ -36,6 +66,9 @@ gateway: per_seconds: 20 capacity: 11000 safety_margin: 1000 + tx_throttler_per_secs: 20 + tx_throttler_capacity: 11000 # To allow actual capacity of 10k, after accounting for safety margin. + tx_throttler_safety_margin: 1000 readiness_checker: retry: max_attempts: 30 @@ -48,10 +81,14 @@ gateway: max_concurrency: 250 capacity: 11000 safety_margin: 1000 + delegated_user_decrypt: + max_concurrency: 250 + capacity: 11000 + safety_margin: 1000 contracts: - decryption_address: "0xB8Ae44365c45A7C5256b14F607CaE23BC040c354" - input_verification_address: "0xE61cff9C581c7c91AEF682c2C10e8632864339ab" - user_decrypt_shares_threshold: 9 + decryption_address: "0x35760912360E875DA50D40a74305575c23D55783" + input_verification_address: "0x1ceFA8E3F3271358218B52c33929Cf76078004c1" + user_decrypt_shares_threshold: 1 # KMS centralized mode log: format: "compact" # compact, pretty, or json @@ -78,7 +115,48 @@ http: metrics: histogram_buckets: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 40] api_retry_after_seconds: 4 - + # Dynamic retry-after configuration for V2 handlers + # Computes Retry-After based on queue size, drain rate, and processing stage + retry_after: + # Minimum retry interval in seconds (floor) + min_seconds: 1 + # Maximum retry interval in seconds (ceiling/cap) + # Use this to limit the maximum retry-after value (e.g., 60s or 120s for tighter bounds) + max_seconds: 300 + + # Safety margin: multiplier applied to computed ETA + # Formula: final_eta = computed_eta * (1 + safety_margin) + # Range: 0.0 to 1.0 (0% to 100% buffer) + # Example: 0.2 = 20% buffer, so 10s ETA becomes 12s + safety_margin: 0.2 + + # Nominal processing times per stage (used for ETA computation) + # These are admin-updatable at runtime via /admin/config + # All fields are required - no defaults in code + nominal_times: + # Expected time for readiness check (user/public decrypt only) + readiness_check_seconds: 4 + # Expected processing time for input proof requests + input_proof_processing_seconds: 2 + # Expected processing time for user decrypt requests + user_decrypt_processing_seconds: 6 + # Expected processing time for public decrypt requests + public_decrypt_processing_seconds: 6 + # Expected time for blockchain TX confirmation (in milliseconds) + tx_confirmation_ms: 250 + + # Backoff intervals for ReceiptReceived state ONLY + # This is the only state where we can't compute a dynamic ETA because + # Copro/KMS response time is unpredictable. + # Format: [elapsed_threshold_seconds, retry_interval_seconds] + # As time in ReceiptReceived increases, we back off polling frequency. + # NOTE: Safety margin is NOT applied to backoff intervals + copro_kms_backoff_intervals: + - [0, 4] # 0-60s: retry every 4s (expect response soon) + - [60, 10] # 60s-2m: retry every 10s + - [120, 30] # 2-5m: retry every 30s + - [300, 60] # 5-15m: retry every 60s + - [900, 300] # 15m+: retry every 5m (likely stuck) metrics: endpoint: "0.0.0.0:9898" @@ -86,9 +164,10 @@ metrics: pool_wait_duration_seconds_histogram_bucket: [0.0001, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 3.0] request_status_duration_histogram_bucket: [0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 300.0, 600.0, 1800.0, 3600.0] transaction_duration_secs_histogram_bucket: [0.01, 0.1, 0.25, 0.50, 0.75, 1.0, 1.25, 1.5, 2.0, 5.0, 10.0] + retry_after_raw_eta_histogram_bucket: [1, 2, 5, 10, 20, 30, 60, 120, 300, 600, 1200, 2400] storage: - sql_database_url: "postgresql://postgres:postgres@localhost:5433/relayer_db" + sql_database_url: "postgresql://postgres:postgres@relayer-db:5433/relayer_db" app_pool: max_connections: 10 min_connections: 2 @@ -104,7 +183,7 @@ storage: sql_health_check_timeout_secs: 5 cron: # 60s for interval check - timeout_cron_interval: "60s" + timeout_cron_interval: "60s" # 30 mins timeout logic public_decrypt_timeout: "30m" user_decrypt_timeout: "30m" diff --git a/test-suite/fhevm/fhevm-cli b/test-suite/fhevm/fhevm-cli index c45f559ffe..f59491e47d 100755 --- a/test-suite/fhevm/fhevm-cli +++ b/test-suite/fhevm/fhevm-cli @@ -37,11 +37,11 @@ export HOST_VERSION=${HOST_VERSION:-"v0.10.9"} # Other services. export CORE_VERSION=${CORE_VERSION:-"v0.13.0-rc.1"} -export RELAYER_VERSION=${RELAYER_VERSION:-"v0.8.4"} -export RELAYER_MIGRATE_VERSION=${RELAYER_MIGRATE_VERSION:-"v0.8.3"} +export RELAYER_VERSION=${RELAYER_VERSION:-"v0.9.0-rc.1"} +export RELAYER_MIGRATE_VERSION=${RELAYER_MIGRATE_VERSION:-"v0.9.0-rc.1"} # Test-suite docker image cannot be updated with 0.10.x releases because of the introduction a # breaking change in delegate user decryption tests in https://github.com/zama-ai/fhevm/pull/1092 -export TEST_SUITE_VERSION=${TEST_SUITE_VERSION:-"ebb1a48"} +export TEST_SUITE_VERSION=${TEST_SUITE_VERSION:-"e9821bb"} function print_logo() { @@ -63,7 +63,7 @@ function usage { echo -e " ${YELLOW}deploy${RESET} ${CYAN}[--build] [--local]${RESET} WIP: Deploy the full fhevm stack (optionally rebuild images)" echo -e " ${YELLOW}pause${RESET} ${CYAN}[CONTRACTS]${RESET} Pause specific contracts (host|gateway)" echo -e " ${YELLOW}unpause${RESET} ${CYAN}[CONTRACTS]${RESET} Unpause specific contracts (host|gateway)" - echo -e " ${YELLOW}test${RESET} ${CYAN}[TYPE]${RESET} Run tests (input-proof|user-decryption|public-decryption|delegate-user-decryption|random|random-subset|operators|erc20|debug)" + echo -e " ${YELLOW}test${RESET} ${CYAN}[TYPE]${RESET} Run tests (input-proof|user-decryption|public-decryption|delegated-user-decryption|random|random-subset|operators|erc20|debug)" echo -e " ${YELLOW}upgrade${RESET} ${CYAN}[SERVICE]${RESET} Upgrade specific service (host|gateway|connector|coprocessor|relayer|test-suite)" echo -e " ${YELLOW}clean${RESET} Remove all containers and volumes" echo -e " ${YELLOW}logs${RESET} ${CYAN}[SERVICE]${RESET} View logs for a specific service" @@ -240,16 +240,9 @@ case $COMMAND in log_message="${LIGHT_BLUE}${BOLD}[TEST] USER DECRYPTION${RESET}" docker_args+=("-g" "test user decrypt") ;; - delegate-user-decryption) - log_message="${LIGHT_BLUE}${BOLD}[TEST] USER DECRYPTION${RESET}" - docker_args+=("-g" "test delegation and revocation propagation") - echo -e "${log_message}" - docker exec fhevm-test-suite-e2e-debug "${docker_args[@]}" - echo Checking transaction-sender logs for DelegateUserDecryption - ./fhevm-cli logs coprocessor-transaction-sender | grep "DelegateUserDecryption txn succeeded" - echo Checking transaction-sender logs for RevokeUserDecryptionDelegation - ./fhevm-cli logs coprocessor-transaction-sender | grep "RevokeUserDecryptionDelegation txn succeeded" - exit 0 # the test has been called unlike other case branch + delegated-user-decryption) + log_message="${LIGHT_BLUE}${BOLD}[TEST] DELEGATED USER DECRYPTION${RESET}" + docker_args+=("-g" "test delegated user decrypt") ;; public-decryption) log_message="${LIGHT_BLUE}${BOLD}[TEST] PUBLIC DECRYPTION${RESET}"