@cofhe/hardhat-plugin 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/gas.ts ADDED
@@ -0,0 +1,227 @@
1
+ import chalk from 'chalk';
2
+ import { ethers } from 'ethers';
3
+ import { MOCKS_TASK_MANAGER_ADDRESS } from '@cofhe/sdk';
4
+ import type { HardhatRuntimeEnvironment } from 'hardhat/types';
5
+
6
+ /// Topic of MockCoFHE's `MockGasConsumed(uint256)` event, emitted once per block of
7
+ /// mock-only work (op replication, decrypt-task storage, log building) with the gas it
8
+ /// consumed. Summing these per transaction gives the mock overhead of that transaction.
9
+ export const MOCK_GAS_CONSUMED_TOPIC = ethers.id('MockGasConsumed(uint256)');
10
+
11
+ /// Minimal receipt shape - compatible with ethers v6 TransactionReceipt and viem receipts.
12
+ export type AdjustableGasReceipt = {
13
+ gasUsed: bigint | number | string;
14
+ logs?: ReadonlyArray<{ address: string; topics: ReadonlyArray<string>; data: string }>;
15
+ };
16
+
17
+ export type AdjustedGasBreakdown = {
18
+ /** Raw gas used by the transaction (includes mock overhead). */
19
+ gasUsed: bigint;
20
+ /** Gas consumed by mock-only work (FHE op replication, decrypt-task storage, mock logging). */
21
+ mockGas: bigint;
22
+ /** gasUsed - mockGas: an estimate of what the transaction would cost on a real CoFHE network. */
23
+ adjustedGasUsed: bigint;
24
+ /** Number of MockGasConsumed events - roughly the number of mock-only blocks executed. */
25
+ mockGasEvents: number;
26
+ };
27
+
28
+ /// Computes the gas breakdown of a transaction receipt by summing the MockGasConsumed
29
+ /// events emitted by the mock task manager. Pure function of the receipt - no RPC calls.
30
+ /// On a real network (no mock events in the logs) `adjustedGasUsed` equals `gasUsed`.
31
+ /// Caveat: `mockGas` is measured pre-refund while `gasUsed` is post-refund, so transactions
32
+ /// that earn large gas refunds (storage clearing) can slightly overstate the mock share;
33
+ /// `adjustedGasUsed` is clamped at 0 for the pathological case.
34
+ export const mock_getAdjustedGasBreakdown = (receipt: AdjustableGasReceipt): AdjustedGasBreakdown => {
35
+ let mockGas = 0n;
36
+ let mockGasEvents = 0;
37
+
38
+ for (const log of receipt.logs ?? []) {
39
+ if (
40
+ log.address.toLowerCase() === MOCKS_TASK_MANAGER_ADDRESS.toLowerCase() &&
41
+ log.topics[0] === MOCK_GAS_CONSUMED_TOPIC
42
+ ) {
43
+ mockGas += BigInt(log.data);
44
+ mockGasEvents += 1;
45
+ }
46
+ }
47
+
48
+ const gasUsed = BigInt(receipt.gasUsed);
49
+ return { gasUsed, mockGas, adjustedGasUsed: mockGas > gasUsed ? 0n : gasUsed - mockGas, mockGasEvents };
50
+ };
51
+
52
+ /// Returns the receipt's gas usage excluding mock overhead - an estimate of what the
53
+ /// transaction would cost on a real CoFHE network. See mock_getAdjustedGasBreakdown.
54
+ export const mock_getAdjustedGasUsed = (receipt: AdjustableGasReceipt): bigint =>
55
+ mock_getAdjustedGasBreakdown(receipt).adjustedGasUsed;
56
+
57
+ // =====================
58
+ // END-OF-RUN SUMMARY
59
+ // =====================
60
+
61
+ type MethodAggregate = {
62
+ to: string;
63
+ selector: string;
64
+ calls: number;
65
+ totalGasUsed: bigint;
66
+ totalMockGas: bigint;
67
+ };
68
+
69
+ const shortAddress = (address: string) => `${address.slice(0, 6)}..${address.slice(-4)}`;
70
+
71
+ /// Builds a selector -> "signature" map from every compiled artifact so summary rows can
72
+ /// show method names instead of raw selectors.
73
+ const buildSelectorNameMap = async (hre: HardhatRuntimeEnvironment): Promise<Map<string, string>> => {
74
+ const selectorNames = new Map<string, string>();
75
+ for (const fqn of await hre.artifacts.getAllFullyQualifiedNames()) {
76
+ try {
77
+ const artifact = await hre.artifacts.readArtifact(fqn);
78
+ const iface = new ethers.Interface(artifact.abi);
79
+ iface.forEachFunction((fn) => {
80
+ if (!selectorNames.has(fn.selector)) selectorNames.set(fn.selector, fn.format('sighash'));
81
+ });
82
+ } catch {
83
+ // Artifacts without a usable ABI are skipped
84
+ }
85
+ }
86
+ return selectorNames;
87
+ };
88
+
89
+ /// Resolves deployed addresses to contract names by matching on-chain code against
90
+ /// artifact deployedBytecode. Best effort: contracts with immutables won't match and
91
+ /// fall back to a shortened address.
92
+ const buildAddressNameMap = async (
93
+ hre: HardhatRuntimeEnvironment,
94
+ addresses: string[]
95
+ ): Promise<Map<string, string>> => {
96
+ const codeToName = new Map<string, string>();
97
+ for (const fqn of await hre.artifacts.getAllFullyQualifiedNames()) {
98
+ try {
99
+ const artifact = await hre.artifacts.readArtifact(fqn);
100
+ if (artifact.deployedBytecode && artifact.deployedBytecode !== '0x') {
101
+ codeToName.set(artifact.deployedBytecode.toLowerCase(), artifact.contractName);
102
+ }
103
+ } catch {
104
+ // skip
105
+ }
106
+ }
107
+
108
+ const addressNames = new Map<string, string>();
109
+ for (const address of addresses) {
110
+ try {
111
+ const code: string = await hre.network.provider.send('eth_getCode', [address, 'latest']);
112
+ addressNames.set(address, codeToName.get(code.toLowerCase()) ?? shortAddress(address));
113
+ } catch {
114
+ addressNames.set(address, shortAddress(address));
115
+ }
116
+ }
117
+ return addressNames;
118
+ };
119
+
120
+ /// Prints a per-method gas summary for every transaction that performed mock FHE work,
121
+ /// showing raw gas (as reported on the mock network) next to adjusted gas (an estimate of
122
+ /// real-network cost). Enabled via `cofhe: { gasSummary: true }` in the hardhat config;
123
+ /// runs after the test task completes.
124
+ export const printMockGasSummary = async (hre: HardhatRuntimeEnvironment) => {
125
+ // Only meaningful against the in-process Hardhat network: elsewhere the mocks aren't
126
+ // auto-deployed and a genesis-to-latest eth_getLogs could hammer an external RPC.
127
+ if (hre.network.name !== 'hardhat') return;
128
+
129
+ let logs: Array<{ transactionHash: string; data: string }>;
130
+ try {
131
+ logs = await hre.network.provider.send('eth_getLogs', [
132
+ { fromBlock: '0x0', toBlock: 'latest', address: MOCKS_TASK_MANAGER_ADDRESS, topics: [MOCK_GAS_CONSUMED_TOPIC] },
133
+ ]);
134
+ } catch {
135
+ return;
136
+ }
137
+ if (!logs || logs.length === 0) return;
138
+
139
+ // Sum mock gas per transaction
140
+ const mockGasPerTx = new Map<string, bigint>();
141
+ for (const log of logs) {
142
+ mockGasPerTx.set(log.transactionHash, (mockGasPerTx.get(log.transactionHash) ?? 0n) + BigInt(log.data));
143
+ }
144
+
145
+ // Aggregate per called contract + method; deployments (FHE ops in constructors) are
146
+ // attributed to the created contract under a '(deployment)' method label.
147
+ const aggregates = new Map<string, MethodAggregate>();
148
+ for (const [txHash, mockGas] of mockGasPerTx) {
149
+ try {
150
+ const receipt = await hre.network.provider.send('eth_getTransactionReceipt', [txHash]);
151
+ const tx = await hre.network.provider.send('eth_getTransactionByHash', [txHash]);
152
+ if (!receipt) continue;
153
+ const target = tx?.to ?? receipt.contractAddress;
154
+ if (!target) continue;
155
+
156
+ const to = ethers.getAddress(target);
157
+ const selector = tx?.to ? (tx.input ?? '0x').slice(0, 10) : '(deployment)';
158
+ const key = `${to}:${selector}`;
159
+ const aggregate = aggregates.get(key) ?? { to, selector, calls: 0, totalGasUsed: 0n, totalMockGas: 0n };
160
+ aggregate.calls += 1;
161
+ aggregate.totalGasUsed += BigInt(receipt.gasUsed);
162
+ aggregate.totalMockGas += mockGas;
163
+ aggregates.set(key, aggregate);
164
+ } catch {
165
+ // Skip transactions that can't be resolved
166
+ }
167
+ }
168
+ if (aggregates.size === 0) return;
169
+
170
+ const selectorNames = await buildSelectorNameMap(hre);
171
+ const addressNames = await buildAddressNameMap(hre, [...new Set([...aggregates.values()].map((a) => a.to))]);
172
+
173
+ // Merge per-address aggregates by resolved contract name + method, so multiple deployed
174
+ // instances of the same contract (one per test, typically) share a row.
175
+ const merged = new Map<string, { contract: string; method: string } & Omit<MethodAggregate, 'to' | 'selector'>>();
176
+ for (const a of aggregates.values()) {
177
+ const contract = addressNames.get(a.to) ?? shortAddress(a.to);
178
+ const method = selectorNames.get(a.selector) ?? a.selector;
179
+ const key = `${contract}:${method}`;
180
+ const row = merged.get(key) ?? { contract, method, calls: 0, totalGasUsed: 0n, totalMockGas: 0n };
181
+ row.calls += a.calls;
182
+ row.totalGasUsed += a.totalGasUsed;
183
+ row.totalMockGas += a.totalMockGas;
184
+ merged.set(key, row);
185
+ }
186
+
187
+ const rows = [...merged.values()]
188
+ .map((r) => {
189
+ const avgGasUsed = r.totalGasUsed / BigInt(r.calls);
190
+ const avgMockGas = r.totalMockGas / BigInt(r.calls);
191
+ return {
192
+ contract: r.contract,
193
+ method: r.method,
194
+ calls: r.calls,
195
+ avgGasUsed,
196
+ avgAdjusted: avgMockGas > avgGasUsed ? 0n : avgGasUsed - avgMockGas,
197
+ overheadPct: avgGasUsed === 0n ? 0 : Math.min(100, Number((avgMockGas * 100n) / avgGasUsed)),
198
+ };
199
+ })
200
+ .sort((a, b) => a.contract.localeCompare(b.contract) || a.method.localeCompare(b.method));
201
+
202
+ const headers = ['Contract', 'Method', 'Calls', 'Avg gas (mocks)', 'Avg gas (adjusted)', 'Mock overhead'];
203
+ const cells = rows.map((r) => [
204
+ r.contract,
205
+ r.method,
206
+ String(r.calls),
207
+ r.avgGasUsed.toLocaleString(),
208
+ r.avgAdjusted.toLocaleString(),
209
+ `${r.overheadPct}%`,
210
+ ]);
211
+ const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((row) => row[i].length)));
212
+ const line = (row: string[]) => `│ ${row.map((cell, i) => cell.padEnd(widths[i])).join(' │ ')} │`;
213
+ const divider = (l: string, m: string, r: string) => `${l}${widths.map((w) => '─'.repeat(w + 2)).join(m)}${r}`;
214
+
215
+ console.log('');
216
+ console.log(chalk.bold('[COFHE-MOCKS] Gas summary') + chalk.dim(' — adjusted ≈ cost excluding mock-only overhead'));
217
+ console.log(divider('┌', '┬', '┐'));
218
+ console.log(line(headers));
219
+ console.log(divider('├', '┼', '┤'));
220
+ for (const row of cells) console.log(line(row));
221
+ console.log(divider('└', '┴', '┘'));
222
+ console.log(
223
+ chalk.dim(
224
+ 'Raw numbers include on-chain replication of off-chain CoFHE work; adjusted numbers estimate real-network cost. estimateGas remains unadjusted.'
225
+ )
226
+ );
227
+ };
package/src/index.ts CHANGED
@@ -22,12 +22,21 @@ import { deployMocks, type DeployMocksArgs, type LogMocksDeploy } from './deploy
22
22
  import { mock_setLoggingEnabled, mock_withLogs } from './logging.js';
23
23
  import { getFixedMockContract, mock_expectPlaintext } from './utils.js';
24
24
  import { mock_getPlaintext } from './utils.js';
25
+ import {
26
+ mock_getAdjustedGasBreakdown,
27
+ mock_getAdjustedGasUsed,
28
+ printMockGasSummary,
29
+ type AdjustableGasReceipt,
30
+ type AdjustedGasBreakdown,
31
+ } from './gas.js';
25
32
  import { hardhat } from '@cofhe/sdk/chains';
26
33
  import {
27
34
  MockThresholdNetworkArtifact,
35
+ MockCoFHEAddressBookArtifact,
28
36
  MockTaskManagerArtifact,
29
37
  MockZkVerifierArtifact,
30
38
  type MockACL,
39
+ type MockCoFHEAddressBook,
31
40
  type MockTaskManager,
32
41
  type MockThresholdNetwork,
33
42
  type MockZkVerifier,
@@ -44,6 +53,14 @@ declare module 'hardhat/types/config' {
44
53
  logMocks?: boolean;
45
54
  /** Whether to show gas usage warnings for mock operations (default: true) */
46
55
  gasWarning?: boolean;
56
+ /**
57
+ * Print a per-method gas summary after `hardhat test`, showing raw gas next to
58
+ * adjusted gas (mock-only overhead excluded - an estimate of real-network cost).
59
+ * Reconstructed from chain history: transactions rolled back by loadFixture /
60
+ * evm_revert won't appear - use getAdjustedGasUsed(receipt) for exact numbers.
61
+ * (default: false)
62
+ */
63
+ gasSummary?: boolean;
47
64
  /**
48
65
  * Controls deploy-mocks console output.
49
66
  * - `''` — silent, no output
@@ -60,6 +77,8 @@ declare module 'hardhat/types/config' {
60
77
  logMocks: boolean;
61
78
  /** Whether to show gas usage warnings for mock operations (default: true) */
62
79
  gasWarning: boolean;
80
+ /** Print a per-method adjusted-gas summary after `hardhat test` (default: false) */
81
+ gasSummary: boolean;
63
82
  mocksDeployVerbosity: LogMocksDeploy;
64
83
  };
65
84
  }
@@ -132,27 +151,26 @@ subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS).setAction(async (taskArgs: { sou
132
151
  });
133
152
 
134
153
  extendConfig((config, userConfig) => {
135
- // Allow users to override the localcofhe network config
136
- if (userConfig.networks && userConfig.networks.localcofhe) {
137
- return;
154
+ // Inject localcofhe preset only when the user hasn't defined it.
155
+ // Must not early-return: sepolia presets and config.cofhe still need to run
156
+ // (Hardhat 3 plugin already scopes the skip this way).
157
+ if (!userConfig.networks?.localcofhe) {
158
+ config.networks.localcofhe = {
159
+ gas: 'auto',
160
+ gasMultiplier: 1.2,
161
+ gasPrice: 'auto',
162
+ timeout: 10_000,
163
+ httpHeaders: {},
164
+ url: 'http://127.0.0.1:42069',
165
+ accounts: [
166
+ '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
167
+ '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
168
+ '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
169
+ '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6',
170
+ ],
171
+ };
138
172
  }
139
173
 
140
- // Default config
141
- config.networks.localcofhe = {
142
- gas: 'auto',
143
- gasMultiplier: 1.2,
144
- gasPrice: 'auto',
145
- timeout: 10_000,
146
- httpHeaders: {},
147
- url: 'http://127.0.0.1:42069',
148
- accounts: [
149
- '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
150
- '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
151
- '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
152
- '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6',
153
- ],
154
- };
155
-
156
174
  // Only add Sepolia config if user hasn't defined it
157
175
  if (!userConfig.networks?.['eth-sepolia']) {
158
176
  config.networks['eth-sepolia'] = {
@@ -185,6 +203,7 @@ extendConfig((config, userConfig) => {
185
203
  config.cofhe = {
186
204
  logMocks: userConfig.cofhe?.logMocks ?? false,
187
205
  gasWarning: userConfig.cofhe?.gasWarning ?? false,
206
+ gasSummary: userConfig.cofhe?.gasSummary ?? false,
188
207
  mocksDeployVerbosity: (userConfig.cofhe?.mocksDeployVerbosity ?? 'v') as LogMocksDeploy,
189
208
  };
190
209
  });
@@ -243,7 +262,12 @@ task(TASK_TEST, 'Deploy mock contracts on hardhat').setAction(async ({}, hre, ru
243
262
  mocksDeployVerbosity: hre.config.cofhe.mocksDeployVerbosity,
244
263
  });
245
264
  }
246
- return runSuper();
265
+
266
+ const result = await runSuper();
267
+ if (hre.config.cofhe.gasSummary) {
268
+ await printMockGasSummary(hre);
269
+ }
270
+ return result;
247
271
  });
248
272
 
249
273
  task(TASK_NODE, 'Deploy mock contracts on hardhat').setAction(async ({}, hre, runSuper) => {
@@ -277,6 +301,7 @@ export * from './utils.js';
277
301
  export * from './fund.js';
278
302
  export * from './logging.js';
279
303
  export * from './deploy.js';
304
+ export * from './gas.js';
280
305
 
281
306
  /**
282
307
  * Runtime environment extensions for the CoFHE Hardhat plugin.
@@ -321,6 +346,26 @@ declare module 'hardhat/types/runtime' {
321
346
  */
322
347
  createClientWithBatteries: (signer?: HardhatEthersSigner) => Promise<CofheClient>;
323
348
 
349
+ /**
350
+ * Returns a transaction's gas usage excluding mock-only overhead (FHE op replication,
351
+ * decrypt-task storage, mock logging) - an estimate of what the transaction would cost
352
+ * on a real CoFHE network. Pure function of the receipt (sums the mock task manager's
353
+ * MockGasConsumed events from `receipt.logs`); on a real network the receipt carries no
354
+ * such events and the raw `gasUsed` is returned unchanged.
355
+ * @param {AdjustableGasReceipt} receipt - The transaction receipt (ethers or viem shape)
356
+ * @returns {bigint} gasUsed minus mock overhead
357
+ */
358
+ getAdjustedGasUsed: (receipt: AdjustableGasReceipt) => bigint;
359
+
360
+ /**
361
+ * Full gas breakdown of a transaction receipt: raw `gasUsed`, the mock-only `mockGas`,
362
+ * the `adjustedGasUsed` (raw minus mock), and the number of mock gas events.
363
+ * See getAdjustedGasUsed.
364
+ * @param {AdjustableGasReceipt} receipt - The transaction receipt (ethers or viem shape)
365
+ * @returns {AdjustedGasBreakdown} The breakdown
366
+ */
367
+ getAdjustedGasBreakdown: (receipt: AdjustableGasReceipt) => AdjustedGasBreakdown;
368
+
324
369
  mocks: {
325
370
  /**
326
371
  * **[MOCKS ONLY]**
@@ -393,6 +438,12 @@ declare module 'hardhat/types/runtime' {
393
438
  */
394
439
  expectPlaintext: (ctHash: bigint | string, expectedValue: bigint) => Promise<void>;
395
440
 
441
+ /**
442
+ * Get the MockCoFHEAddressBook contract (typed via typechain)
443
+ * @returns {Promise<MockCoFHEAddressBook>} The MockCoFHEAddressBook contract
444
+ */
445
+ getMockCoFHEAddressBook: () => Promise<MockCoFHEAddressBook>;
446
+
396
447
  /**
397
448
  * Get the MockTaskManager contract (typed via typechain)
398
449
  * @returns {Promise<MockTaskManager>} The MockTaskManager contract
@@ -493,6 +544,8 @@ extendEnvironment((hre) => {
493
544
  // Return client
494
545
  return client;
495
546
  },
547
+ getAdjustedGasUsed: mock_getAdjustedGasUsed,
548
+ getAdjustedGasBreakdown: mock_getAdjustedGasBreakdown,
496
549
  mocks: {
497
550
  withLogs: async (closureName: string, closure: () => Promise<void>) => {
498
551
  return mock_withLogs(hre, closureName, closure);
@@ -514,6 +567,8 @@ extendEnvironment((hre) => {
514
567
  const [signer] = await hre.ethers.getSigners();
515
568
  return mock_expectPlaintext(signer.provider, ctHash, expectedValue);
516
569
  },
570
+ getMockCoFHEAddressBook: async () =>
571
+ getFixedMockContract(hre, MockCoFHEAddressBookArtifact) as unknown as Promise<MockCoFHEAddressBook>,
517
572
  getMockTaskManager: async () =>
518
573
  getFixedMockContract(hre, MockTaskManagerArtifact) as unknown as Promise<MockTaskManager>,
519
574
  getMockACL: async () => {
package/src/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { TASK_MANAGER_ADDRESS, MOCKS_ZK_VERIFIER_ADDRESS } from '@cofhe/sdk';
1
+ import { MOCKS_TASK_MANAGER_ADDRESS, MOCKS_ZK_VERIFIER_ADDRESS } from '@cofhe/sdk';
2
2
  import { expect } from 'chai';
3
3
  import { Contract, ethers } from 'ethers';
4
4
  import { type HardhatEthersProvider } from '@nomicfoundation/hardhat-ethers/internal/hardhat-ethers-provider';
@@ -43,8 +43,10 @@ const mock_checkIsTestnet = async (fnName: string, provider: HardhatEthersProvid
43
43
  // Get bytecode at ZK_VERIFIER_ADDRESS
44
44
  const bytecode = await provider.getCode(MOCKS_ZK_VERIFIER_ADDRESS);
45
45
 
46
- // If bytecode is empty, we are on a testnet
47
- const isTestnet = bytecode.length === 0;
46
+ // provider.getCode() (eth_getCode) returns the string "0x" -- never a zero-length string --
47
+ // when there is no contract deployed at the address, so a real "no bytecode" check must
48
+ // compare against that, not against string length 0.
49
+ const isTestnet = !bytecode || bytecode.length <= 2;
48
50
 
49
51
  // Log if we are on a testnet
50
52
  if (isTestnet) {
@@ -63,7 +65,7 @@ export const mock_getPlaintext = async (
63
65
 
64
66
  // Connect to MockTaskManager
65
67
  const taskManager = new ethers.Contract(
66
- TASK_MANAGER_ADDRESS,
68
+ MOCKS_TASK_MANAGER_ADDRESS,
67
69
  ['function mockStorage(uint256) view returns (uint256)'],
68
70
  provider
69
71
  );
@@ -83,7 +85,7 @@ export const mock_getPlaintextExists = async (
83
85
 
84
86
  // Connect to MockTaskManager
85
87
  const taskManager = new ethers.Contract(
86
- TASK_MANAGER_ADDRESS,
88
+ MOCKS_TASK_MANAGER_ADDRESS,
87
89
  ['function inMockStorage(uint256) view returns (bool)'],
88
90
  provider
89
91
  );