Skip to content

Commit 8fac779

Browse files
authored
Merge of #2046
2 parents 9b600a5 + ebe3727 commit 8fac779

File tree

5 files changed

+241
-2
lines changed

5 files changed

+241
-2
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,11 @@ jobs:
215215
run: |
216216
./fhevm-cli test random-subset
217217
218+
- name: HCU block cap test
219+
working-directory: test-suite/fhevm
220+
run: |
221+
./fhevm-cli test hcu-block-cap
222+
218223
- name: Host listener poller test
219224
working-directory: test-suite/fhevm
220225
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: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import { expect } from 'chai';
2+
import { ethers } from 'hardhat';
23

34
import { createInstances } from '../instance';
45
import { getSigners, initSigners } from '../signers';
5-
import { getTxHCUFromTxReceipt } from '../utils';
6+
import { getTxHCUFromTxReceipt, mineNBlocks } from '../utils';
67
import { deployEncryptedERC20Fixture } from './EncryptedERC20.fixture';
78

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

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)