Skip to content

Commit 21e15cc

Browse files
authored
Entropy tester (#2762)
* feat(entropy-tester)
1 parent 293a844 commit 21e15cc

File tree

8 files changed

+512
-210
lines changed

8 files changed

+512
-210
lines changed

apps/entropy-tester/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
lib

apps/entropy-tester/cli/run.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env node
2+
import { main } from "../dist/index.js";
3+
main();
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"chain-id": "berachain_mainnet",
4+
"interval": "3h"
5+
},
6+
{
7+
"chain-id": "apechain_mainnet",
8+
"interval": "6h"
9+
},
10+
{
11+
"chain-id": "blast",
12+
"interval": "10m"
13+
}
14+
]

apps/entropy-tester/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { nextjs as default } from "@cprussin/eslint-config";

apps/entropy-tester/package.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"name": "@pythnetwork/entropy-tester",
3+
"version": "1.0.0",
4+
"description": "Utility to test entropy provider callbacks",
5+
"type": "module",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
"import": {
10+
"types": "./dist/index.d.ts",
11+
"default": "./dist/index.js"
12+
}
13+
},
14+
"files": [
15+
"dist/**/*",
16+
"cli/**/*"
17+
],
18+
"scripts": {
19+
"build": "tsc",
20+
"fix:format": "prettier --write .",
21+
"fix:lint": "eslint --fix .",
22+
"test:format": "prettier --check .",
23+
"test:lint": "eslint . --max-warnings 0",
24+
"test:types": "tsc",
25+
"start": "tsc && node cli/run.js"
26+
},
27+
"repository": {
28+
"type": "git",
29+
"url": "git+https://github.com/pyth-network/pyth-crosschain.git",
30+
"directory": "apps/entropy-tester"
31+
},
32+
"bin": {
33+
"pyth-entropy-tester": "./cli/run.js"
34+
},
35+
"devDependencies": {
36+
"@cprussin/eslint-config": "catalog:",
37+
"@cprussin/prettier-config": "catalog:",
38+
"@cprussin/tsconfig": "catalog:",
39+
"@types/express": "^4.17.21",
40+
"@types/yargs": "^17.0.10",
41+
"@typescript-eslint/eslint-plugin": "^6.0.0",
42+
"@typescript-eslint/parser": "^6.0.0",
43+
"eslint": "catalog:",
44+
"pino-pretty": "^11.2.1",
45+
"prettier": "catalog:",
46+
"ts-node": "catalog:",
47+
"typescript": "catalog:"
48+
},
49+
"dependencies": {
50+
"@pythnetwork/contract-manager": "workspace:*",
51+
"joi": "^17.6.0",
52+
"pino": "catalog:",
53+
"prom-client": "^15.1.0",
54+
"viem": "catalog:",
55+
"yargs": "^17.5.1",
56+
"zod": "catalog:"
57+
},
58+
"keywords": [],
59+
"author": "",
60+
"license": "Apache-2.0"
61+
}

apps/entropy-tester/src/index.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import fs from "node:fs/promises";
2+
3+
import type { PrivateKey } from "@pythnetwork/contract-manager/core/base";
4+
import { toPrivateKey } from "@pythnetwork/contract-manager/core/base";
5+
import { EvmEntropyContract } from "@pythnetwork/contract-manager/core/contracts/evm";
6+
import { DefaultStore } from "@pythnetwork/contract-manager/node/store";
7+
import type { Logger } from "pino";
8+
import { pino } from "pino";
9+
import yargs from "yargs";
10+
import { hideBin } from "yargs/helpers";
11+
import { z } from "zod";
12+
13+
type LoadedConfig = {
14+
contract: EvmEntropyContract;
15+
interval: number;
16+
};
17+
18+
function timeToSeconds(timeStr: string): number {
19+
const match = /^(\d+)([hms])$/i.exec(timeStr);
20+
if (!match?.[1] || !match[2])
21+
throw new Error("Invalid format. Use formats like '6h', '15m', or '30s'.");
22+
23+
const value = Number.parseInt(match[1], 10);
24+
const unit = match[2].toLowerCase();
25+
26+
switch (unit) {
27+
case "h": {
28+
return value * 3600;
29+
}
30+
case "m": {
31+
return value * 60;
32+
}
33+
case "s": {
34+
return value;
35+
}
36+
default: {
37+
throw new Error("Unsupported time unit.");
38+
}
39+
}
40+
}
41+
42+
async function loadConfig(configPath: string): Promise<LoadedConfig[]> {
43+
const configSchema = z.array(
44+
z.strictObject({
45+
"chain-id": z.string(),
46+
interval: z.string(),
47+
}),
48+
);
49+
const configContent = (await import(configPath, {
50+
with: { type: "json" },
51+
})) as { default: string };
52+
const configs = configSchema.parse(configContent.default);
53+
const loadedConfigs = configs.map((config) => {
54+
const interval = timeToSeconds(config.interval);
55+
const contracts = Object.values(DefaultStore.entropy_contracts).filter(
56+
(contract) => contract.chain.getId() == config["chain-id"],
57+
);
58+
const firstContract = contracts[0];
59+
if (contracts.length === 0 || !firstContract) {
60+
throw new Error(
61+
`Can not find the contract for chain ${config["chain-id"]}, check contract manager store.`,
62+
);
63+
}
64+
if (contracts.length > 1) {
65+
throw new Error(
66+
`Multiple contracts found for chain ${config["chain-id"]}, check contract manager store.`,
67+
);
68+
}
69+
return { contract: firstContract, interval };
70+
});
71+
return loadedConfigs;
72+
}
73+
74+
async function testLatency(
75+
contract: EvmEntropyContract,
76+
privateKey: PrivateKey,
77+
logger: Logger,
78+
) {
79+
const provider = await contract.getDefaultProvider();
80+
const userRandomNumber = contract.generateUserRandomNumber();
81+
const requestResponseSchema = z.object({
82+
transactionHash: z.string(),
83+
events: z.object({
84+
RequestedWithCallback: z.object({
85+
returnValues: z.object({
86+
sequenceNumber: z.string(),
87+
}),
88+
}),
89+
}),
90+
});
91+
const requestResponse = requestResponseSchema.parse(
92+
await contract.requestRandomness(
93+
userRandomNumber,
94+
provider,
95+
privateKey,
96+
true, // with callback
97+
),
98+
);
99+
// Read the sequence number for the request from the transaction events.
100+
const sequenceNumber = Number.parseInt(
101+
requestResponse.events.RequestedWithCallback.returnValues.sequenceNumber,
102+
);
103+
logger.info(
104+
{ sequenceNumber, txHash: requestResponse.transactionHash },
105+
`Request submitted`,
106+
);
107+
108+
const startTime = Date.now();
109+
110+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
111+
while (true) {
112+
await new Promise((resolve) => setTimeout(resolve, 2000));
113+
const request = await contract.getRequest(provider, sequenceNumber);
114+
logger.debug(request);
115+
116+
if (Number.parseInt(request.sequenceNumber) === 0) {
117+
// 0 means the request is cleared
118+
const endTime = Date.now();
119+
logger.info(
120+
{ sequenceNumber, latency: endTime - startTime },
121+
`Successful callback`,
122+
);
123+
break;
124+
}
125+
if (Date.now() - startTime > 60_000) {
126+
logger.error(
127+
{ sequenceNumber },
128+
"Timeout: 60s passed without the callback being called",
129+
);
130+
break;
131+
}
132+
}
133+
}
134+
135+
const RUN_OPTIONS = {
136+
validate: {
137+
description: "Only validate the configs and exit",
138+
type: "boolean",
139+
default: false,
140+
required: false,
141+
},
142+
config: {
143+
description: "Yaml config file",
144+
type: "string",
145+
required: true,
146+
},
147+
"private-key": {
148+
type: "string",
149+
required: true,
150+
description:
151+
"Path to the private key to sign the transactions with. Should be hex encoded",
152+
},
153+
} as const;
154+
155+
export const main = function () {
156+
yargs(hideBin(process.argv))
157+
.parserConfiguration({
158+
"parse-numbers": false,
159+
})
160+
.command(
161+
"run",
162+
"run the tester until manually stopped",
163+
RUN_OPTIONS,
164+
async (argv) => {
165+
const logger = pino();
166+
const configs = await loadConfig(argv.config);
167+
if (argv.validate) {
168+
logger.info("Config validated");
169+
return;
170+
}
171+
const privateKeyFileContent = await fs.readFile(
172+
argv["private-key"],
173+
"utf8",
174+
);
175+
const privateKey = toPrivateKey(
176+
privateKeyFileContent.replace("0x", "").trimEnd(),
177+
);
178+
logger.info("Running");
179+
const promises = configs.map(async ({ contract, interval }) => {
180+
const child = logger.child({ chain: contract.chain.getId() });
181+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
182+
while (true) {
183+
try {
184+
await testLatency(contract, privateKey, child);
185+
} catch (error) {
186+
child.error(error, "Error testing latency");
187+
}
188+
await new Promise((resolve) =>
189+
setTimeout(resolve, interval * 1000),
190+
);
191+
}
192+
});
193+
await Promise.all(promises);
194+
},
195+
)
196+
.demandCommand()
197+
.help();
198+
};

apps/entropy-tester/tsconfig.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "@cprussin/tsconfig/nextjs.json",
3+
"include": ["**/*.ts", "**/*.tsx"],
4+
"exclude": ["node_modules"]
5+
}

0 commit comments

Comments
 (0)