Skip to content

Commit ce3fac9

Browse files
authored
Merge of #2081
2 parents 5385563 + 1d11f82 commit ce3fac9

File tree

7 files changed

+286
-6
lines changed

7 files changed

+286
-6
lines changed

.github/workflows/test-suite-e2e-tests.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ on:
6767
description: "KMS Core version"
6868
default: ""
6969
type: string
70+
deploy-build:
71+
description: "Build local Docker images from the checked out repository before deploy"
72+
default: false
73+
type: boolean
7074
workflow_call:
7175
secrets:
7276
GHCR_READ_TOKEN:
@@ -139,8 +143,14 @@ jobs:
139143
140144
- name: Deploy fhevm Stack
141145
working-directory: test-suite/fhevm
146+
env:
147+
DEPLOY_BUILD: ${{ inputs.deploy-build }}
142148
run: |
143-
./fhevm-cli deploy
149+
if [[ "$DEPLOY_BUILD" == 'true' ]]; then
150+
./fhevm-cli deploy --build
151+
else
152+
./fhevm-cli deploy
153+
fi
144154
145155
# E2E tests on pausing the Host contracts
146156
- name: Pause Host Contracts
@@ -215,6 +225,11 @@ jobs:
215225
run: |
216226
./fhevm-cli test random-subset
217227
228+
- name: HCU block cap test
229+
working-directory: test-suite/fhevm
230+
run: |
231+
./fhevm-cli test hcu-block-cap
232+
218233
- name: Host listener poller test
219234
working-directory: test-suite/fhevm
220235
run: |

test-suite/e2e/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ ACL_CONTRACT_ADDRESS="0x05fD9B5EFE0a996095f42Ed7e77c390810CF660c"
1414
KMS_VERIFIER_CONTRACT_ADDRESS="0xcCAe95fF1d11656358E782570dF0418F59fA40e1"
1515
INPUT_VERIFIER_CONTRACT_ADDRESS="0xa1880e99d86F081E8D3868A8C4732C8f65dfdB11"
1616
FHEVM_EXECUTOR_CONTRACT_ADDRESS="0x12B064FB845C1cc05e9493856a1D637a73e944bE"
17+
HCU_LIMIT_CONTRACT_ADDRESS=""
18+
DEPLOYER_PRIVATE_KEY=""
1719
TEST_INPUT_CONTRACT_ADDRESS=""
1820

1921
HARDHAT_NETWORK="staging"

test-suite/e2e/test/encryptedERC20/EncryptedERC20.HCU.ts

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
11
import { expect } from 'chai';
2+
import type { TransactionResponse } from 'ethers';
3+
import { ethers } from 'hardhat';
24

35
import { createInstances } from '../instance';
46
import { getSigners, initSigners } from '../signers';
5-
import { getTxHCUFromTxReceipt } from '../utils';
7+
import { getTxHCUFromTxReceipt, mineNBlocks, waitForPendingTransactions, waitForTransactionReceipt } from '../utils';
68
import { deployEncryptedERC20Fixture } from './EncryptedERC20.fixture';
79

10+
// Minimal ABI for HCULimit — the contract is deployed by the host-sc stack
11+
// but not compiled in the E2E test suite.
12+
const HCU_LIMIT_ABI = [
13+
'function getBlockMeter() view returns (uint48, uint48)',
14+
'function getGlobalHCUCapPerBlock() view returns (uint48)',
15+
'function getMaxHCUPerTx() view returns (uint48)',
16+
'function getMaxHCUDepthPerTx() view returns (uint48)',
17+
'function setHCUPerBlock(uint48)',
18+
'function setMaxHCUPerTx(uint48)',
19+
'function setMaxHCUDepthPerTx(uint48)',
20+
'function addToBlockHCUWhitelist(address)',
21+
'function removeFromBlockHCUWhitelist(address)',
22+
'function isBlockHCUWhitelisted(address) view returns (bool)',
23+
'error NotHostOwner(address)',
24+
];
25+
826
describe('EncryptedERC20:HCU', function () {
927
before(async function () {
1028
await initSigners(2);
@@ -86,4 +104,212 @@ describe('EncryptedERC20:HCU', function () {
86104
// Le euint64 (149000) + And ebool (25000) + Select euint64 (55000) + Sub euint64 (162000)
87105
expect(HCUMaxDepthTransferFrom).to.eq(391_000, 'HCU Depth incorrect');
88106
});
107+
108+
describe('block cap scenarios', function () {
109+
const BATCHED_TRANSFER_GAS_LIMIT = 1_000_000;
110+
const RECEIPT_TIMEOUT_MS = 300_000;
111+
let savedHCUPerBlock: bigint;
112+
let savedMaxHCUPerTx: bigint;
113+
let savedMaxHCUDepthPerTx: bigint;
114+
let wasWhitelisted: boolean;
115+
116+
async function waitForConfirmedTx(tx: TransactionResponse, label: string) {
117+
console.log(`[HCU] waiting ${label} ${tx.hash}`);
118+
const receipt = await tx.wait(1, RECEIPT_TIMEOUT_MS);
119+
console.log(`[HCU] mined ${label} ${tx.hash} block=${receipt?.blockNumber} status=${receipt?.status}`);
120+
return receipt;
121+
}
122+
123+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
124+
async function sendEncryptedTransfer(ctx: any, sender: string, recipient: string, amount: number, overrides?: any) {
125+
const erc20 = ctx.erc20.connect(ctx.signers[sender]);
126+
const input = ctx.instances[sender].createEncryptedInput(ctx.contractAddress, ctx.signers[sender].address);
127+
input.add64(amount);
128+
const enc = await input.encrypt();
129+
return erc20['transfer(address,bytes32,bytes)'](recipient, enc.handles[0], enc.inputProof, overrides ?? {});
130+
}
131+
132+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
133+
async function mintAndDistribute(ctx: any) {
134+
const mintTx = await ctx.erc20.mint(10000);
135+
await mintTx.wait();
136+
const setupTx = await sendEncryptedTransfer(ctx, 'alice', ctx.signers.bob.address, 5000);
137+
await setupTx.wait();
138+
}
139+
140+
before(async function () {
141+
const hcuLimitAddress = process.env.HCU_LIMIT_CONTRACT_ADDRESS;
142+
if (!hcuLimitAddress) {
143+
throw new Error('HCU_LIMIT_CONTRACT_ADDRESS env var is required for block cap tests');
144+
}
145+
this.hcuLimit = new ethers.Contract(hcuLimitAddress, HCU_LIMIT_ABI, ethers.provider);
146+
147+
const deployerKey = process.env.DEPLOYER_PRIVATE_KEY;
148+
if (!deployerKey) {
149+
throw new Error('DEPLOYER_PRIVATE_KEY env var is required for block cap tests');
150+
}
151+
this.deployer = new ethers.Wallet(deployerKey, ethers.provider);
152+
});
153+
154+
beforeEach(async function () {
155+
[savedHCUPerBlock, savedMaxHCUPerTx, savedMaxHCUDepthPerTx, wasWhitelisted] = await Promise.all([
156+
this.hcuLimit.getGlobalHCUCapPerBlock(),
157+
this.hcuLimit.getMaxHCUPerTx(),
158+
this.hcuLimit.getMaxHCUDepthPerTx(),
159+
this.hcuLimit.isBlockHCUWhitelisted(this.contractAddress),
160+
]);
161+
});
162+
163+
afterEach(async function () {
164+
// Restore automine + 1-second interval mining (Anvil --block-time 1)
165+
await ethers.provider.send('evm_setAutomine', [true]);
166+
await ethers.provider.send('evm_setIntervalMining', [1]);
167+
168+
const ownerHcuLimit = this.hcuLimit.connect(this.deployer);
169+
await (await ownerHcuLimit.setHCUPerBlock(savedHCUPerBlock)).wait();
170+
await (await ownerHcuLimit.setMaxHCUPerTx(savedMaxHCUPerTx)).wait();
171+
await (await ownerHcuLimit.setMaxHCUDepthPerTx(savedMaxHCUDepthPerTx)).wait();
172+
173+
const isWhitelisted = await this.hcuLimit.isBlockHCUWhitelisted(this.contractAddress);
174+
if (wasWhitelisted && !isWhitelisted) {
175+
await (await ownerHcuLimit.addToBlockHCUWhitelist(this.contractAddress)).wait();
176+
} else if (!wasWhitelisted && isWhitelisted) {
177+
await (await ownerHcuLimit.removeFromBlockHCUWhitelist(this.contractAddress)).wait();
178+
}
179+
});
180+
181+
describe('with lowered limits', function () {
182+
const TIGHT_DEPTH_PER_TX = 400_000;
183+
const TIGHT_MAX_PER_TX = 600_000;
184+
const TIGHT_PER_BLOCK = 600_000;
185+
186+
beforeEach(async function () {
187+
// Narrowest-first when lowering: hcuPerBlock >= maxHCUPerTx >= maxHCUDepthPerTx
188+
const ownerHcuLimit = this.hcuLimit.connect(this.deployer);
189+
await (await ownerHcuLimit.setMaxHCUDepthPerTx(TIGHT_DEPTH_PER_TX)).wait();
190+
await (await ownerHcuLimit.setMaxHCUPerTx(TIGHT_MAX_PER_TX)).wait();
191+
await (await ownerHcuLimit.setHCUPerBlock(TIGHT_PER_BLOCK)).wait();
192+
});
193+
194+
it('should accumulate HCU across users until the block cap is exhausted', async function () {
195+
await mintAndDistribute(this);
196+
197+
await mineNBlocks(1);
198+
await ethers.provider.send('evm_setIntervalMining', [0]);
199+
await ethers.provider.send('evm_setAutomine', [false]);
200+
201+
// Alice fills the cap, Bob would push block total over — use fixed gasLimit
202+
// to bypass estimateGas (which reverts against pending state)
203+
const tx1 = await sendEncryptedTransfer(this, 'alice', this.signers.carol.address, 100, {
204+
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
205+
});
206+
const tx2 = await sendEncryptedTransfer(this, 'bob', this.signers.carol.address, 100, {
207+
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
208+
});
209+
await waitForPendingTransactions([tx1.hash, tx2.hash]);
210+
211+
await ethers.provider.send('evm_mine');
212+
await ethers.provider.send('evm_setAutomine', [true]);
213+
await ethers.provider.send('evm_setIntervalMining', [1]);
214+
215+
const receipt1 = await waitForTransactionReceipt(tx1.hash);
216+
expect(receipt1?.status).to.eq(1, 'First transfer should succeed');
217+
218+
// Use getTransactionReceipt to avoid ethers throwing on reverted tx
219+
const receipt2 = await ethers.provider.getTransactionReceipt(tx2.hash);
220+
expect(receipt2?.status).to.eq(0, 'Second transfer should revert (block cap exceeded)');
221+
expect(receipt1?.blockNumber).to.eq(receipt2?.blockNumber);
222+
});
223+
224+
it('should allow previously blocked caller to succeed after block rollover', async function () {
225+
await mintAndDistribute(this);
226+
227+
// Block N: alice fills the cap, bob gets blocked
228+
await mineNBlocks(1);
229+
await ethers.provider.send('evm_setIntervalMining', [0]);
230+
await ethers.provider.send('evm_setAutomine', [false]);
231+
232+
const txAlice = await sendEncryptedTransfer(this, 'alice', this.signers.carol.address, 100, {
233+
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
234+
});
235+
const txBob = await sendEncryptedTransfer(this, 'bob', this.signers.carol.address, 100, {
236+
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
237+
});
238+
await waitForPendingTransactions([txAlice.hash, txBob.hash]);
239+
240+
await ethers.provider.send('evm_mine');
241+
await ethers.provider.send('evm_setAutomine', [true]);
242+
await ethers.provider.send('evm_setIntervalMining', [1]);
243+
244+
const receiptAlice = await waitForTransactionReceipt(txAlice.hash);
245+
expect(receiptAlice?.status).to.eq(1, 'Alice should succeed');
246+
247+
const receiptBob = await ethers.provider.getTransactionReceipt(txBob.hash);
248+
expect(receiptBob?.status).to.eq(0, 'Bob should be blocked in block N');
249+
250+
// Block N+1: meter resets, bob retries and succeeds
251+
await mineNBlocks(1);
252+
253+
const [, usedHCUAfterReset] = await this.hcuLimit.getBlockMeter();
254+
expect(usedHCUAfterReset).to.eq(0n, 'Meter should reset after new block');
255+
256+
const retryBob = await sendEncryptedTransfer(this, 'bob', this.signers.carol.address, 100);
257+
const receiptRetry = await retryBob.wait();
258+
expect(receiptRetry?.status).to.eq(1, 'Bob should succeed after rollover');
259+
});
260+
});
261+
262+
it('should count HCU after whitelist removal', async function () {
263+
const ownerHcuLimit = this.hcuLimit.connect(this.deployer);
264+
265+
// Use manual mining (automine=false + explicit evm_mine) to avoid
266+
// the unreliable automine+intervalMining(0) combo that hangs in CI.
267+
await ethers.provider.send('evm_setIntervalMining', [0]);
268+
await ethers.provider.send('evm_setAutomine', [false]);
269+
270+
const mintTx = await this.erc20.mint(10000);
271+
await ethers.provider.send('evm_mine');
272+
const mintReceipt = await waitForTransactionReceipt(mintTx.hash);
273+
expect(mintReceipt.status).to.eq(1, 'Mint should succeed');
274+
275+
const whitelistTx = await ownerHcuLimit.addToBlockHCUWhitelist(this.contractAddress);
276+
await ethers.provider.send('evm_mine');
277+
await waitForTransactionReceipt(whitelistTx.hash);
278+
279+
// Advance to a fresh block so the transfer starts with a clean meter
280+
await mineNBlocks(1);
281+
282+
// Transfer while whitelisted — meter stays at 0
283+
const tx1 = await sendEncryptedTransfer(this, 'alice', this.signers.bob.address, 100, {
284+
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
285+
});
286+
await ethers.provider.send('evm_mine');
287+
await waitForTransactionReceipt(tx1.hash);
288+
289+
const [, usedHCUWhitelisted] = await this.hcuLimit.getBlockMeter();
290+
expect(usedHCUWhitelisted).to.eq(0n, 'Whitelisted contract should not count HCU');
291+
292+
const unwhitelistTx = await ownerHcuLimit.removeFromBlockHCUWhitelist(this.contractAddress);
293+
await ethers.provider.send('evm_mine');
294+
await waitForTransactionReceipt(unwhitelistTx.hash);
295+
296+
// Transfer after removal — meter should count HCU
297+
const tx2 = await sendEncryptedTransfer(this, 'alice', this.signers.bob.address, 100, {
298+
gasLimit: BATCHED_TRANSFER_GAS_LIMIT,
299+
});
300+
await ethers.provider.send('evm_mine');
301+
await waitForTransactionReceipt(tx2.hash);
302+
303+
const [, usedHCUAfterRemoval] = await this.hcuLimit.getBlockMeter();
304+
expect(usedHCUAfterRemoval).to.be.greaterThan(0n, 'Should count HCU after whitelist removal');
305+
});
306+
307+
it('should reject setHCUPerBlock from non-owner', async function () {
308+
const aliceHcuLimit = this.hcuLimit.connect(this.signers.alice);
309+
await expect(aliceHcuLimit.setHCUPerBlock(1_000_000)).to.be.revertedWithCustomError(
310+
this.hcuLimit,
311+
'NotHostOwner',
312+
);
313+
});
314+
});
89315
});

test-suite/e2e/test/encryptedERC20/EncryptedERC20.fixture.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import { ethers } from 'hardhat';
22

33
import type { EncryptedERC20 } from '../../types/contracts';
44
import { getSigners } from '../signers';
5+
import { waitForTransactionReceipt } from '../utils';
56

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

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

13-
return contract;
18+
return contractFactory.attach(receipt.contractAddress) as EncryptedERC20;
1419
}

test-suite/e2e/test/utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ export const mineNBlocks = async (n: number) => {
8686
}
8787
};
8888

89+
export const waitForPendingTransactions = async (txHashes: string[], timeoutMs = 30_000): Promise<void> => {
90+
const start = Date.now();
91+
while (Date.now() - start < timeoutMs) {
92+
const pendingBlock = await ethers.provider.send('eth_getBlockByNumber', ['pending', false]);
93+
const pendingTxHashes = new Set<string>(pendingBlock?.transactions ?? []);
94+
if (txHashes.every((txHash) => pendingTxHashes.has(txHash))) {
95+
return;
96+
}
97+
await new Promise((resolve) => setTimeout(resolve, 50));
98+
}
99+
throw new Error(`Timed out waiting for pending txs: ${txHashes.join(', ')}`);
100+
};
101+
102+
export const waitForTransactionReceipt = async (txHash: string, timeoutMs = 30_000): Promise<TransactionReceipt> => {
103+
const start = Date.now();
104+
while (Date.now() - start < timeoutMs) {
105+
const receipt = await ethers.provider.getTransactionReceipt(txHash);
106+
if (receipt) {
107+
return receipt;
108+
}
109+
await new Promise((resolve) => setTimeout(resolve, 50));
110+
}
111+
throw new Error(`Timed out waiting for receipt: ${txHash}`);
112+
};
113+
89114
export const bigIntToBytes64 = (value: bigint) => {
90115
return new Uint8Array(toBufferBE(value, 64));
91116
};

test-suite/fhevm/env/staging/.env.test-suite

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ KMS_VERIFIER_CONTRACT_ADDRESS=0xa1880e99d86F081E8D3868A8C4732C8f65dfdB11
2626
ACL_CONTRACT_ADDRESS=0x05fD9B5EFE0a996095f42Ed7e77c390810CF660c
2727
INPUT_VERIFIER_CONTRACT_ADDRESS=0x857Ca72A957920Fa0FB138602995839866Bd4005
2828
FHEVM_EXECUTOR_CONTRACT_ADDRESS=0xcCAe95fF1d11656358E782570dF0418F59fA40e1
29+
HCU_LIMIT_CONTRACT_ADDRESS=0xAb30999D17FAAB8c95B2eCD500cFeFc8f658f15d
30+
# accounts[9] deployer — needed for HCU limit owner-restricted tests
31+
DEPLOYER_PRIVATE_KEY=2d24c36c57e6bfbf90c43173481cc00edcbd1a3922de5e5fdb9aba5fc4e0fafd
2932

3033
# =============================================================================
3134
# SERVICE ENDPOINTS

test-suite/fhevm/fhevm-cli

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ function usage {
8080
echo -e " ${YELLOW}deploy${RESET} ${CYAN}[--build] [--local] [--coprocessors N] [--coprocessor-threshold T]${RESET} Deploy fhevm stack"
8181
echo -e " ${YELLOW}pause${RESET} ${CYAN}[CONTRACTS]${RESET} Pause specific contracts (host|gateway)"
8282
echo -e " ${YELLOW}unpause${RESET} ${CYAN}[CONTRACTS]${RESET} Unpause specific contracts (host|gateway)"
83-
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)"
83+
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)"
8484
echo -e " ${YELLOW}smoke${RESET} ${CYAN}[PROFILE]${RESET} Run multicoproc smoke profile (multi-2-2|multi-3-5)"
8585
echo -e " ${YELLOW}upgrade${RESET} ${CYAN}[SERVICE]${RESET} Upgrade specific service (host|gateway|connector|coprocessor|relayer|test-suite)"
8686
echo -e " ${YELLOW}clean${RESET} Remove all containers and volumes"
@@ -386,6 +386,10 @@ case $COMMAND in
386386
log_message="${LIGHT_BLUE}${BOLD}[TEST] RANDOM OPERATORS (SUBSET)${RESET}"
387387
docker_args+=("-g" "64 bits generate and decrypt|generating rand in reverting sub-call|64 bits generate with upper bound and decrypt")
388388
;;
389+
hcu-block-cap)
390+
log_message="${LIGHT_BLUE}${BOLD}[TEST] HCU BLOCK CAP${RESET}"
391+
docker_args+=("-g" "block cap scenarios")
392+
;;
389393
paused-host-contracts)
390394
log_message="${LIGHT_BLUE}${BOLD}[TEST] PAUSED HOST CONTRACTS${RESET}"
391395
docker_args+=("-g" "test paused host.*")

0 commit comments

Comments
 (0)