Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ba988db
test(test-suite): add e2e block cap tests for HCU metering (#1099)
Eikix Mar 5, 2026
35d9266
fix(test-suite): address review feedback on HCU block cap tests
Eikix Mar 5, 2026
447d671
fix(test-suite): fix HCU block cap tests for real stack
Eikix Mar 5, 2026
b20059f
fix(test-suite): tighten accumulation assertion with 2% tolerance
Eikix Mar 5, 2026
5ae0794
fix(test-suite): replace HCU tolerance with self-consistent accumulat…
Eikix Mar 5, 2026
16776c2
fix(test-suite): use revertedWithCustomError for non-owner assertion
Eikix Mar 5, 2026
925f42f
refactor(test-suite): simplify HCU block cap test structure
Eikix Mar 5, 2026
c9dcc45
refactor(test-suite): simplify accumulation test to use block meter only
Eikix Mar 5, 2026
bcba67f
fix(test-suite): assert meter starts at 0 before first operation
Eikix Mar 5, 2026
8a41960
refactor(test-suite): tighten accumulation assertions
Eikix Mar 5, 2026
44accaa
ci: temporarily skip all tests except HCU block cap
Eikix Mar 5, 2026
bf5fe53
fix(ci): build test-suite from source to include new HCU tests
Eikix Mar 5, 2026
289ddd1
fix(test): fix NotHostOwner ABI signature and relax accumulation asse…
Eikix Mar 5, 2026
12e40fd
fix(test): relax meter3 assertion — same op can differ by ~18 HCU
Eikix Mar 5, 2026
ff65a98
fix(test): disable Anvil interval mining when batching txs in one block
Eikix Mar 5, 2026
e26a0ca
refactor(test): centralize interval mining control in beforeEach/afte…
Eikix Mar 6, 2026
ce6e2b1
revert: restore per-test interval mining control (beforeEach hangs)
Eikix Mar 6, 2026
201c618
revert(ci): remove temporary test filters and --build flag
Eikix Mar 6, 2026
95c8198
test(test-suite): always restore HCU state after block cap tests
Eikix Mar 6, 2026
aaddd68
fix(test-suite): restore HCU whitelist state safely
Eikix Mar 6, 2026
52e9d5a
fix(test-suite): stabilize HCU meter assertions
Eikix Mar 6, 2026
ebe3727
Merge branch 'main' into host-statevar-tests
Eikix Mar 6, 2026
7631069
fix(test-suite): harden HCU e2e tests and add build dispatch
Eikix Mar 9, 2026
167691c
ci(test-suite): avoid expression expansion in deploy step
Eikix Mar 9, 2026
21a6f6e
fix(test-suite): stabilize HCU whitelist removal test
Eikix Mar 9, 2026
c4e5e78
test(test-suite): instrument HCU whitelist tx waits
Eikix Mar 9, 2026
ef4cf9e
fix(test-suite): use manual mining for HCU whitelist removal test
Eikix Mar 9, 2026
7930337
feat(test-suite): add --resume/--only to fhevm-cli and optimize CI de…
Eikix Mar 9, 2026
8a03585
fix(test-suite): remove --remove-orphans from selective cleanup
Eikix Mar 9, 2026
9caa67e
fix(ci): revert --only test-suite optimization in deploy step
Eikix Mar 9, 2026
9fa3918
chore(test-suite): revert unrelated fhevm-cli and deploy script changes
Eikix Mar 9, 2026
1d11f82
fix(test-suite): re-add hcu-block-cap test type to fhevm-cli
Eikix Mar 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/test-suite-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ on:
description: "KMS Core version"
default: ""
type: string
deploy-build:
description: "Build local Docker images from the checked out repository before deploy"
default: false
type: boolean
workflow_call:
secrets:
GHCR_READ_TOKEN:
Expand Down Expand Up @@ -139,8 +143,14 @@ jobs:

- name: Deploy fhevm Stack
working-directory: test-suite/fhevm
env:
DEPLOY_BUILD: ${{ inputs.deploy-build }}
run: |
./fhevm-cli deploy
if [[ "$DEPLOY_BUILD" == 'true' ]]; then
./fhevm-cli deploy --build
else
./fhevm-cli deploy
fi

# E2E tests on pausing the Host contracts
- name: Pause Host Contracts
Expand Down Expand Up @@ -215,6 +225,11 @@ jobs:
run: |
./fhevm-cli test random-subset

- name: HCU block cap test
working-directory: test-suite/fhevm
run: |
./fhevm-cli test hcu-block-cap

- name: Host listener poller test
working-directory: test-suite/fhevm
run: |
Expand Down
2 changes: 2 additions & 0 deletions test-suite/e2e/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ ACL_CONTRACT_ADDRESS="0x05fD9B5EFE0a996095f42Ed7e77c390810CF660c"
KMS_VERIFIER_CONTRACT_ADDRESS="0xcCAe95fF1d11656358E782570dF0418F59fA40e1"
INPUT_VERIFIER_CONTRACT_ADDRESS="0xa1880e99d86F081E8D3868A8C4732C8f65dfdB11"
FHEVM_EXECUTOR_CONTRACT_ADDRESS="0x12B064FB845C1cc05e9493856a1D637a73e944bE"
HCU_LIMIT_CONTRACT_ADDRESS=""
DEPLOYER_PRIVATE_KEY=""
TEST_INPUT_CONTRACT_ADDRESS=""

HARDHAT_NETWORK="staging"
Expand Down
228 changes: 227 additions & 1 deletion test-suite/e2e/test/encryptedERC20/EncryptedERC20.HCU.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { expect } from 'chai';
import type { TransactionResponse } from 'ethers';
import { ethers } from 'hardhat';

import { createInstances } from '../instance';
import { getSigners, initSigners } from '../signers';
import { getTxHCUFromTxReceipt } from '../utils';
import { getTxHCUFromTxReceipt, mineNBlocks, waitForPendingTransactions, waitForTransactionReceipt } from '../utils';
import { deployEncryptedERC20Fixture } from './EncryptedERC20.fixture';

// Minimal ABI for HCULimit — the contract is deployed by the host-sc stack
// but not compiled in the E2E test suite.
const HCU_LIMIT_ABI = [
'function getBlockMeter() view returns (uint48, uint48)',
'function getGlobalHCUCapPerBlock() view returns (uint48)',
'function getMaxHCUPerTx() view returns (uint48)',
'function getMaxHCUDepthPerTx() view returns (uint48)',
'function setHCUPerBlock(uint48)',
'function setMaxHCUPerTx(uint48)',
'function setMaxHCUDepthPerTx(uint48)',
'function addToBlockHCUWhitelist(address)',
'function removeFromBlockHCUWhitelist(address)',
'function isBlockHCUWhitelisted(address) view returns (bool)',
'error NotHostOwner(address)',
];

describe('EncryptedERC20:HCU', function () {
before(async function () {
await initSigners(2);
Expand Down Expand Up @@ -86,4 +104,212 @@ describe('EncryptedERC20:HCU', function () {
// Le euint64 (149000) + And ebool (25000) + Select euint64 (55000) + Sub euint64 (162000)
expect(HCUMaxDepthTransferFrom).to.eq(391_000, 'HCU Depth incorrect');
});

describe('block cap scenarios', function () {
const BATCHED_TRANSFER_GAS_LIMIT = 1_000_000;
const RECEIPT_TIMEOUT_MS = 300_000;
let savedHCUPerBlock: bigint;
let savedMaxHCUPerTx: bigint;
let savedMaxHCUDepthPerTx: bigint;
let wasWhitelisted: boolean;

async function waitForConfirmedTx(tx: TransactionResponse, label: string) {
console.log(`[HCU] waiting ${label} ${tx.hash}`);
const receipt = await tx.wait(1, RECEIPT_TIMEOUT_MS);
console.log(`[HCU] mined ${label} ${tx.hash} block=${receipt?.blockNumber} status=${receipt?.status}`);
return receipt;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function sendEncryptedTransfer(ctx: any, sender: string, recipient: string, amount: number, overrides?: any) {
const erc20 = ctx.erc20.connect(ctx.signers[sender]);
const input = ctx.instances[sender].createEncryptedInput(ctx.contractAddress, ctx.signers[sender].address);
input.add64(amount);
const enc = await input.encrypt();
return erc20['transfer(address,bytes32,bytes)'](recipient, enc.handles[0], enc.inputProof, overrides ?? {});
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function mintAndDistribute(ctx: any) {
const mintTx = await ctx.erc20.mint(10000);
await mintTx.wait();
const setupTx = await sendEncryptedTransfer(ctx, 'alice', ctx.signers.bob.address, 5000);
await setupTx.wait();
}

before(async function () {
const hcuLimitAddress = process.env.HCU_LIMIT_CONTRACT_ADDRESS;
if (!hcuLimitAddress) {
throw new Error('HCU_LIMIT_CONTRACT_ADDRESS env var is required for block cap tests');
}
this.hcuLimit = new ethers.Contract(hcuLimitAddress, HCU_LIMIT_ABI, ethers.provider);

const deployerKey = process.env.DEPLOYER_PRIVATE_KEY;
if (!deployerKey) {
throw new Error('DEPLOYER_PRIVATE_KEY env var is required for block cap tests');
}
this.deployer = new ethers.Wallet(deployerKey, ethers.provider);
});

beforeEach(async function () {
[savedHCUPerBlock, savedMaxHCUPerTx, savedMaxHCUDepthPerTx, wasWhitelisted] = await Promise.all([
this.hcuLimit.getGlobalHCUCapPerBlock(),
this.hcuLimit.getMaxHCUPerTx(),
this.hcuLimit.getMaxHCUDepthPerTx(),
this.hcuLimit.isBlockHCUWhitelisted(this.contractAddress),
]);
});

afterEach(async function () {
// Restore automine + 1-second interval mining (Anvil --block-time 1)
await ethers.provider.send('evm_setAutomine', [true]);
await ethers.provider.send('evm_setIntervalMining', [1]);

const ownerHcuLimit = this.hcuLimit.connect(this.deployer);
await (await ownerHcuLimit.setHCUPerBlock(savedHCUPerBlock)).wait();
await (await ownerHcuLimit.setMaxHCUPerTx(savedMaxHCUPerTx)).wait();
await (await ownerHcuLimit.setMaxHCUDepthPerTx(savedMaxHCUDepthPerTx)).wait();

const isWhitelisted = await this.hcuLimit.isBlockHCUWhitelisted(this.contractAddress);
if (wasWhitelisted && !isWhitelisted) {
await (await ownerHcuLimit.addToBlockHCUWhitelist(this.contractAddress)).wait();
} else if (!wasWhitelisted && isWhitelisted) {
await (await ownerHcuLimit.removeFromBlockHCUWhitelist(this.contractAddress)).wait();
}
});

describe('with lowered limits', function () {
const TIGHT_DEPTH_PER_TX = 400_000;
const TIGHT_MAX_PER_TX = 600_000;
const TIGHT_PER_BLOCK = 600_000;

beforeEach(async function () {
// Narrowest-first when lowering: hcuPerBlock >= maxHCUPerTx >= maxHCUDepthPerTx
const ownerHcuLimit = this.hcuLimit.connect(this.deployer);
await (await ownerHcuLimit.setMaxHCUDepthPerTx(TIGHT_DEPTH_PER_TX)).wait();
await (await ownerHcuLimit.setMaxHCUPerTx(TIGHT_MAX_PER_TX)).wait();
await (await ownerHcuLimit.setHCUPerBlock(TIGHT_PER_BLOCK)).wait();
});

it('should accumulate HCU across users until the block cap is exhausted', async function () {
await mintAndDistribute(this);

await mineNBlocks(1);
await ethers.provider.send('evm_setIntervalMining', [0]);
await ethers.provider.send('evm_setAutomine', [false]);

// Alice fills the cap, Bob would push block total over — use fixed gasLimit
// to bypass estimateGas (which reverts against pending state)
const tx1 = await sendEncryptedTransfer(this, 'alice', this.signers.carol.address, 100, {
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
});
const tx2 = await sendEncryptedTransfer(this, 'bob', this.signers.carol.address, 100, {
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
});
await waitForPendingTransactions([tx1.hash, tx2.hash]);

await ethers.provider.send('evm_mine');
await ethers.provider.send('evm_setAutomine', [true]);
await ethers.provider.send('evm_setIntervalMining', [1]);

const receipt1 = await waitForTransactionReceipt(tx1.hash);
expect(receipt1?.status).to.eq(1, 'First transfer should succeed');

// Use getTransactionReceipt to avoid ethers throwing on reverted tx
const receipt2 = await ethers.provider.getTransactionReceipt(tx2.hash);
expect(receipt2?.status).to.eq(0, 'Second transfer should revert (block cap exceeded)');
expect(receipt1?.blockNumber).to.eq(receipt2?.blockNumber);
});

it('should allow previously blocked caller to succeed after block rollover', async function () {
await mintAndDistribute(this);

// Block N: alice fills the cap, bob gets blocked
await mineNBlocks(1);
await ethers.provider.send('evm_setIntervalMining', [0]);
await ethers.provider.send('evm_setAutomine', [false]);

const txAlice = await sendEncryptedTransfer(this, 'alice', this.signers.carol.address, 100, {
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
});
const txBob = await sendEncryptedTransfer(this, 'bob', this.signers.carol.address, 100, {
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
});
await waitForPendingTransactions([txAlice.hash, txBob.hash]);

await ethers.provider.send('evm_mine');
await ethers.provider.send('evm_setAutomine', [true]);
await ethers.provider.send('evm_setIntervalMining', [1]);

const receiptAlice = await waitForTransactionReceipt(txAlice.hash);
expect(receiptAlice?.status).to.eq(1, 'Alice should succeed');

const receiptBob = await ethers.provider.getTransactionReceipt(txBob.hash);
expect(receiptBob?.status).to.eq(0, 'Bob should be blocked in block N');

// Block N+1: meter resets, bob retries and succeeds
await mineNBlocks(1);

const [, usedHCUAfterReset] = await this.hcuLimit.getBlockMeter();
expect(usedHCUAfterReset).to.eq(0n, 'Meter should reset after new block');

const retryBob = await sendEncryptedTransfer(this, 'bob', this.signers.carol.address, 100);
const receiptRetry = await retryBob.wait();
expect(receiptRetry?.status).to.eq(1, 'Bob should succeed after rollover');
});
});

it('should count HCU after whitelist removal', async function () {
const ownerHcuLimit = this.hcuLimit.connect(this.deployer);

// Use manual mining (automine=false + explicit evm_mine) to avoid
// the unreliable automine+intervalMining(0) combo that hangs in CI.
await ethers.provider.send('evm_setIntervalMining', [0]);
await ethers.provider.send('evm_setAutomine', [false]);

const mintTx = await this.erc20.mint(10000);
await ethers.provider.send('evm_mine');
const mintReceipt = await waitForTransactionReceipt(mintTx.hash);
expect(mintReceipt.status).to.eq(1, 'Mint should succeed');

const whitelistTx = await ownerHcuLimit.addToBlockHCUWhitelist(this.contractAddress);
await ethers.provider.send('evm_mine');
await waitForTransactionReceipt(whitelistTx.hash);

// Advance to a fresh block so the transfer starts with a clean meter
await mineNBlocks(1);

// Transfer while whitelisted — meter stays at 0
const tx1 = await sendEncryptedTransfer(this, 'alice', this.signers.bob.address, 100, {
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
});
await ethers.provider.send('evm_mine');
await waitForTransactionReceipt(tx1.hash);

const [, usedHCUWhitelisted] = await this.hcuLimit.getBlockMeter();
expect(usedHCUWhitelisted).to.eq(0n, 'Whitelisted contract should not count HCU');

const unwhitelistTx = await ownerHcuLimit.removeFromBlockHCUWhitelist(this.contractAddress);
await ethers.provider.send('evm_mine');
await waitForTransactionReceipt(unwhitelistTx.hash);

// Transfer after removal — meter should count HCU
const tx2 = await sendEncryptedTransfer(this, 'alice', this.signers.bob.address, 100, {
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
});
await ethers.provider.send('evm_mine');
await waitForTransactionReceipt(tx2.hash);

const [, usedHCUAfterRemoval] = await this.hcuLimit.getBlockMeter();
expect(usedHCUAfterRemoval).to.be.greaterThan(0n, 'Should count HCU after whitelist removal');
});

it('should reject setHCUPerBlock from non-owner', async function () {
const aliceHcuLimit = this.hcuLimit.connect(this.signers.alice);
await expect(aliceHcuLimit.setHCUPerBlock(1_000_000)).to.be.revertedWithCustomError(
this.hcuLimit,
'NotHostOwner',
);
});
});
});
11 changes: 8 additions & 3 deletions test-suite/e2e/test/encryptedERC20/EncryptedERC20.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import { ethers } from 'hardhat';

import type { EncryptedERC20 } from '../../types/contracts';
import { getSigners } from '../signers';
import { waitForTransactionReceipt } from '../utils';

export async function deployEncryptedERC20Fixture(): Promise<EncryptedERC20> {
const signers = await getSigners();

const contractFactory = await ethers.getContractFactory('EncryptedERC20');
const contract = await contractFactory.connect(signers.alice).deploy('Naraggara', 'NARA'); // City of Zama's battle
await contract.waitForDeployment();
const deployTx = await contractFactory.getDeployTransaction('Naraggara', 'NARA'); // City of Zama's battle
const tx = await signers.alice.sendTransaction({ ...deployTx, gasLimit: 10_000_000 });
const receipt = await waitForTransactionReceipt(tx.hash);
if (!receipt.contractAddress || receipt.status !== 1) {
throw new Error(`EncryptedERC20 deployment failed: ${tx.hash}`);
}

return contract;
return contractFactory.attach(receipt.contractAddress) as EncryptedERC20;
}
25 changes: 25 additions & 0 deletions test-suite/e2e/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,31 @@ export const mineNBlocks = async (n: number) => {
}
};

export const waitForPendingTransactions = async (txHashes: string[], timeoutMs = 30_000): Promise<void> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const pendingBlock = await ethers.provider.send('eth_getBlockByNumber', ['pending', false]);
const pendingTxHashes = new Set<string>(pendingBlock?.transactions ?? []);
if (txHashes.every((txHash) => pendingTxHashes.has(txHash))) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Timed out waiting for pending txs: ${txHashes.join(', ')}`);
};

export const waitForTransactionReceipt = async (txHash: string, timeoutMs = 30_000): Promise<TransactionReceipt> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const receipt = await ethers.provider.getTransactionReceipt(txHash);
if (receipt) {
return receipt;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Timed out waiting for receipt: ${txHash}`);
};

export const bigIntToBytes64 = (value: bigint) => {
return new Uint8Array(toBufferBE(value, 64));
};
Expand Down
3 changes: 3 additions & 0 deletions test-suite/fhevm/env/staging/.env.test-suite
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ KMS_VERIFIER_CONTRACT_ADDRESS=0xa1880e99d86F081E8D3868A8C4732C8f65dfdB11
ACL_CONTRACT_ADDRESS=0x05fD9B5EFE0a996095f42Ed7e77c390810CF660c
INPUT_VERIFIER_CONTRACT_ADDRESS=0x857Ca72A957920Fa0FB138602995839866Bd4005
FHEVM_EXECUTOR_CONTRACT_ADDRESS=0xcCAe95fF1d11656358E782570dF0418F59fA40e1
HCU_LIMIT_CONTRACT_ADDRESS=0xAb30999D17FAAB8c95B2eCD500cFeFc8f658f15d
# accounts[9] deployer — needed for HCU limit owner-restricted tests
DEPLOYER_PRIVATE_KEY=2d24c36c57e6bfbf90c43173481cc00edcbd1a3922de5e5fdb9aba5fc4e0fafd

# =============================================================================
# SERVICE ENDPOINTS
Expand Down
6 changes: 5 additions & 1 deletion test-suite/fhevm/fhevm-cli
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function usage {
echo -e " ${YELLOW}deploy${RESET} ${CYAN}[--build] [--local] [--coprocessors N] [--coprocessor-threshold T]${RESET} Deploy fhevm stack"
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|delegated-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|hcu-block-cap|debug)"
echo -e " ${YELLOW}smoke${RESET} ${CYAN}[PROFILE]${RESET} Run multicoproc smoke profile (multi-2-2|multi-3-5)"
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"
Expand Down Expand Up @@ -386,6 +386,10 @@ case $COMMAND in
log_message="${LIGHT_BLUE}${BOLD}[TEST] RANDOM OPERATORS (SUBSET)${RESET}"
docker_args+=("-g" "64 bits generate and decrypt|generating rand in reverting sub-call|64 bits generate with upper bound and decrypt")
;;
hcu-block-cap)
log_message="${LIGHT_BLUE}${BOLD}[TEST] HCU BLOCK CAP${RESET}"
docker_args+=("-g" "block cap scenarios")
;;
paused-host-contracts)
log_message="${LIGHT_BLUE}${BOLD}[TEST] PAUSED HOST CONTRACTS${RESET}"
docker_args+=("-g" "test paused host.*")
Expand Down