test: 🧙 Generate Type Bindings for Contracts (#58)

## Summary
This PR adds statically typed bindings for contracts. This allows you to
write E2E tests with full completions in TS.

## Additions

- `ts-build.yml` New CI, this will make sure that if there's changes
made to the contracts that the contract-bindings are up to date.
- `package.json` script changes
- `start:e2e:ci` - Designed to be run with all options specified since
CIs are famously bad with iteractive CLI prompts
  - `test:e2e` - added timeout
- `generate:wagmi` - This generates the smart contract bindings for our
tests
- New Function Helpers:
- `generateRandomAccount()` Returns a viem account type object for a
random account. Useful for tests where you want idempotency on a long
lived network since the state is probabilistically fresh
- `getContractInstance()` Returns a viem contract instance that allows
you to read/write to the deployed contract. You should get full type
inference here for the methods available and parameters required.

### Example

```ts
 it("avs() can be read from contract instance", async () => {
    const value = await instance.read.avs();
    expect(isAddress(value), "AVS getter should return an address").toBeTrue();
  });
```

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Facundo Farall <37149322+ffarall@users.noreply.github.com>
This commit is contained in:
Tim B 2025-05-01 11:14:19 +01:00 committed by GitHub
parent ca9eb0f813
commit fa4d3b8391
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 17705 additions and 71 deletions

View file

@ -58,5 +58,5 @@ jobs:
${{ runner.os }}-foundry-
- run: bun install
- run: bun start:e2e:minimal
- run: bun start:e2e:ci
- run: bun test:e2e

50
.github/workflows/ts-build.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: TS Build
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
generate-wagmi:
runs-on: ubuntu-latest
name: Check Bindings are current
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- uses: oven-sh/setup-bun@v2
- uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Run Forge build
run: |
forge build
working-directory: contracts
- name: Install dependencies
working-directory: test
run: bun install
- name: Generate Wagmi Bindings
working-directory: test
run: bun generate:wagmi
- run: bun fmt:fix
working-directory: test
- name: Check no local changes
run: |
changes=$(git status --porcelain .)
if [ -n "$changes" ]; then
echo "generate:wagmi produced changes:"
echo "$changes"
echo "Please run 'bun generate:wagmi' locally and commit the changes."
exit 1
else
echo "No changes"
exit 0
fi

View file

@ -84,6 +84,16 @@ bun test:e2e
> [!NOTE]
> You can increase the logging level by setting `LOG_LEVEL=debug` before running the tests.
### Wagmi Bindings Generation
To ensure contract bindings are up-to-date, run the following command after modifying smart contracts or updating ABIs:
```bash
bun generate:wagmi
```
This command generates TypeScript bindings for interacting with the deployed smart contracts using Wagmi.
## Troubleshooting
### E2E Network Launch doesn't work

File diff suppressed because it is too large Load diff

Binary file not shown.

5
test/bunfig.toml Normal file
View file

@ -0,0 +1,5 @@
# Keeping this file around to remind us
# to check patch notes to see if they add things here we can use
[test]
# Sadly there isnt any global timeout options here yet

View file

@ -4,7 +4,7 @@ import { logger } from "utils";
export class LaunchedNetwork {
protected runId: string;
protected processes: Bun.Subprocess[];
protected processes: Bun.Subprocess<"inherit" | "pipe" | "ignore", number, number>[];
protected fileDescriptors: number[];
protected DHNodes: { id: string; port: number }[];
@ -33,7 +33,7 @@ export class LaunchedNetwork {
this.fileDescriptors.push(fd);
}
addProcess(process: Bun.Subprocess) {
addProcess(process: Bun.Subprocess<"inherit" | "pipe" | "ignore", number, number>) {
this.processes.push(process);
}

View file

@ -8,6 +8,7 @@ import {
SUBSTRATE_FUNDED_ACCOUNTS,
getPortFromKurtosis,
logger,
parseDeploymentsFile,
parseRelayConfig,
printHeader
} from "utils";
@ -27,13 +28,8 @@ export const performRelayerOperations = async (
) => {
printHeader("Starting Snowbridge Relayers");
logger.info("Preparing to generate configs");
const anvilDeploymentsPath = "../contracts/deployments/anvil.json";
const anvilDeploymentsFile = Bun.file(anvilDeploymentsPath);
if (!(await anvilDeploymentsFile.exists())) {
logger.error(`File ${anvilDeploymentsPath} does not exist`);
throw new Error("Error reading anvil deployments file");
}
const anvilDeployments = await anvilDeploymentsFile.json();
const anvilDeployments = await parseDeploymentsFile();
const beefyClientAddress = anvilDeployments.BeefyClient;
const gatewayAddress = anvilDeployments.Gateway;
invariant(beefyClientAddress, "❌ BeefyClient address not found in anvil.json");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
export * from "./generated";

View file

@ -8,16 +8,18 @@
"fmt": "biome check .",
"fmt:fix": "biome check --write .",
"build:docker:relayer": "bun -e \"import build from './scripts/snowbridge-relayer.ts'; build()\"",
"generate:wagmi": "wagmi generate",
"generate:snowbridge-cfgs": "bun -e \"import {generateSnowbridgeConfigs} from './scripts/gen-snowbridge-cfgs.ts'; await generateSnowbridgeConfigs()\"",
"start:e2e:verified": "bun cli --verified --blockscout --deploy-contracts",
"start:e2e:minimal": "bun cli",
"start:e2e:ci": "bun cli -d --setup-validators --update-validator-set --fund-validators",
"start:e2e:minrelayer": "bun cli --relayer -d --no-setup-validators --no-update-validator-set --no-fund-validators --datahaven",
"stop:e2e": "pkill datahaven ; kurtosis enclave stop datahaven-ethereum && kurtosis clean && kurtosis engine stop && docker container prune -f",
"stop:e2e:verified": "bun stop:e2e",
"stop:e2e:minimal": "bun stop:e2e",
"stop:e2e:quick": "kurtosis enclave stop datahaven-ethereum",
"stop:kurtosis-engine": "kurtosis engine stop && docker container prune -f",
"test:e2e": "bun test suites/e2e",
"test:e2e": "bun test suites/e2e --timeout 30000",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
@ -33,6 +35,8 @@
"@inquirer/prompts": "^7.5.0",
"@types/dockerode": "^3.3.38",
"@types/node": "^22.14.1",
"@wagmi/cli": "^2.3.0",
"@wagmi/core": "^2.17.0",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"dockerode": "^4.0.6",
@ -43,10 +47,17 @@
"pino-pretty": "^13.0.0",
"tiny-invariant": "^1.3.3",
"viem": "^2.28.0",
"wagmi": "^2.15.0",
"zod": "^3.24.3"
},
"trustedDependencies": [
"@biomejs/biome",
"protobufjs"
"bufferutil",
"cpu-features",
"esbuild",
"keccak",
"protobufjs",
"ssh2",
"utf-8-validate"
]
}

View file

@ -1,4 +1,4 @@
import { logger } from "utils";
import { generateRandomAccount, logger } from "utils";
import { http, createWalletClient, defineChain, parseEther, publicActions } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
@ -30,7 +30,7 @@ export default async function main(privateKey: string, networkRpcUrl: string) {
transport: http(networkRpcUrl)
}).extend(publicActions);
const randAccount = privateKeyToAccount(generatePrivateKey());
const randAccount = generateRandomAccount();
const addresses = [
// "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
// "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc",

View file

@ -3,13 +3,11 @@ import {
ANVIL_FUNDED_ACCOUNTS,
type ViemClientInterface,
createDefaultClient,
fetchContractAbiByAddress,
fetchContractAddressByName,
generateRandomAccount,
logger
} from "utils";
import { isAddress, parseAbi, parseEther } from "viem";
import { parseEther } from "viem";
// Tests are disabled because we lack ability to run blockscout reliably
describe("E2E: Read-only", () => {
let api: ViemClientInterface;
@ -34,44 +32,27 @@ describe("E2E: Read-only", () => {
expect(balance).toBeGreaterThan(parseEther("1"));
});
it.skip("Snowbridge contract is deployed and verified", async () => {
const contractAddress = await fetchContractAddressByName("BeefyClient");
logger.info(`Contract BeefyClient deployed to ${contractAddress}`);
expect(isAddress(contractAddress)).toBeTrue();
const contractCode = await api.getCode({ address: contractAddress });
expect(contractCode).toBeTruthy();
describe("BeefyClient contract", async () => {
it("latestBeefyBlock()) can be read", async () => {
const value = await api.readContract({
abi: parseAbi(["function latestBeefyBlock() view returns (uint64)"]),
address: contractAddress,
functionName: "latestBeefyBlock"
});
expect(value, "Expected contract read to give positive blocknum").toBeGreaterThan(0n);
});
it("blockscout can fetch abi", async () => {
const address = await fetchContractAddressByName("BeefyClient");
const abi = await fetchContractAbiByAddress(address);
const resp = await api.readContract({
address,
abi,
functionName: "randaoCommitExpiration"
});
expect(resp, "Expected contract read").toBeGreaterThan(0n);
});
it("can send ETH txs", async () => {
const amount = parseEther("1");
const randomAddress = generateRandomAccount();
const balanceBefore = await api.getBalance({
address: randomAddress.address
});
});
logger.debug(`Balance of ${randomAddress.address} before: ${balanceBefore}`);
it.skip("AVS contract is deployed and verified", async () => {
const contractAddress = await fetchContractAddressByName("DataHavenServiceManager");
logger.info(`Contract DataHavenServiceManager deployed to ${contractAddress}`);
expect(isAddress(contractAddress)).toBeTrue();
const hash = await api.sendTransaction({
to: randomAddress.address,
value: amount
});
const contractCode = await api.getCode({ address: contractAddress });
expect(contractCode).toBeTruthy();
const receipt = await api.waitForTransactionReceipt({ hash });
logger.debug(`Transaction receipt: ${receipt}`);
const balanceAfter = await api.getBalance({
address: randomAddress.address
});
logger.debug(`Balance of ${randomAddress.address} after: ${balanceAfter}`);
expect(balanceAfter - balanceBefore).toBe(amount);
});
});

View file

@ -0,0 +1,46 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { beefyClientAbi } from "contract-bindings";
import {
type AnvilDeployments,
type ContractInstance,
type ViemClientInterface,
createDefaultClient,
getContractInstance,
logger,
parseDeploymentsFile
} from "utils";
import { isAddress } from "viem";
describe("BeefyClient contract", async () => {
let api: ViemClientInterface;
let deployments: AnvilDeployments;
let instance: ContractInstance<"BeefyClient">;
beforeAll(async () => {
api = await createDefaultClient();
deployments = await parseDeploymentsFile();
instance = await getContractInstance("BeefyClient");
});
it("BeefyClient contract is deployed", async () => {
const contractAddress = deployments.BeefyClient;
expect(isAddress(contractAddress)).toBeTrue();
});
it("latestBeefyBlock() can be read", async () => {
const value = await api.readContract({
abi: beefyClientAbi,
functionName: "latestBeefyBlock",
address: deployments.BeefyClient
});
logger.debug(`latestBeefyBlock() value: ${value}`);
expect(value, "Expected contract read to give positive blocknum").toBeGreaterThan(0n);
});
it("latestBeefyBlock() can be read from contract instance", async () => {
const value = await instance.read.latestBeefyBlock();
logger.debug(`latestBeefyBlock() value: ${value}`);
expect(value, "Expected contract read to give positive blocknum").toBeGreaterThan(0n);
});
});

View file

@ -0,0 +1,27 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { beefyClientAbi } from "contract-bindings";
import {
type AnvilDeployments,
type ContractInstance,
type ViemClientInterface,
createDefaultClient,
getContractInstance,
logger,
parseDeploymentsFile
} from "utils";
import { isAddress } from "viem";
describe("BeefyClient contract", async () => {
let instance: ContractInstance<"ServiceManager">;
beforeAll(async () => {
instance = await getContractInstance("ServiceManager");
});
it("avs() can be read from contract instance", async () => {
const value = await instance.read.avs();
logger.debug(`avs() value: ${value}`);
expect(isAddress(value), "AVS getter should return an address").toBeTrue();
});
});

View file

@ -31,6 +31,6 @@
"esModuleInterop": true,
"resolveJsonModule": true,
},
"include": ["utils/*.ts", "scripts/*.ts", "suites/**/*.ts", "cli/**/*.ts"],
"include": ["utils/*.ts", "scripts/*.ts", "suites/**/*.ts", "cli/**/*.ts", "wagmi.config.ts", "contract-bindings/*.ts"],
"exclude": ["node_modules/"]
}

View file

@ -43,7 +43,7 @@ export const ANVIL_FUNDED_ACCOUNTS = {
derivationPath: "m/44'/60'/0'/0/"
} as const;
export const CHAIN_ID = 31337;
export const CHAIN_ID = 3151908;
export const CONTAINER_NAMES = {
EL1: "el-1-reth-lighthouse",

115
test/utils/contracts.ts Normal file
View file

@ -0,0 +1,115 @@
import * as generated from "contract-bindings";
import { type Abi, erc20Abi, getContract, isAddress } from "viem";
import { z } from "zod";
import { logger } from "./logger";
import { type ViemClientInterface, createDefaultClient } from "./viem";
import invariant from "tiny-invariant";
const ethAddressRegex = /^0x[a-fA-F0-9]{40}$/;
const ethAddress = z.string().regex(ethAddressRegex, "Invalid Ethereum address");
const ethAddressCustom = z.custom<`0x${string}`>(
(val) => typeof val === "string" && ethAddressRegex.test(val),
{ message: "Invalid Ethereum address" }
);
const DeployedStrategySchema = z.object({
address: ethAddress,
underlyingToken: ethAddress,
tokenCreator: ethAddress
});
const AnvilDeploymentsSchema = z.object({
network: z.string(),
BeefyClient: ethAddressCustom,
AgentExecutor: ethAddressCustom,
Gateway: ethAddressCustom,
ServiceManager: ethAddressCustom,
VetoableSlasher: ethAddressCustom,
RewardsRegistry: ethAddressCustom,
Agent: ethAddressCustom,
DelegationManager: ethAddressCustom,
StrategyManager: ethAddressCustom,
AVSDirectory: ethAddressCustom,
EigenPodManager: ethAddressCustom,
EigenPodBeacon: ethAddressCustom,
RewardsCoordinator: ethAddressCustom,
AllocationManager: ethAddressCustom,
PermissionController: ethAddressCustom,
ETHPOSDeposit: ethAddressCustom,
BaseStrategyImplementation: ethAddressCustom,
DeployedStrategies: z.array(DeployedStrategySchema)
});
export type AnvilDeployments = z.infer<typeof AnvilDeploymentsSchema>;
export const parseDeploymentsFile = async (): Promise<AnvilDeployments> => {
const anvilDeploymentsPath = "../contracts/deployments/anvil.json";
const anvilDeploymentsFile = Bun.file(anvilDeploymentsPath);
if (!(await anvilDeploymentsFile.exists())) {
logger.error(`File ${anvilDeploymentsPath} does not exist`);
throw new Error("Error reading anvil deployments file");
}
const anvilDeploymentsJson = await anvilDeploymentsFile.json();
try {
const parsedDeployments = AnvilDeploymentsSchema.parse(anvilDeploymentsJson);
logger.debug("Successfully parsed anvil deployments file.");
return parsedDeployments;
} catch (error) {
logger.error("Failed to parse anvil deployments file:", error);
throw new Error("Invalid anvil deployments file format");
}
};
// Add to this if we add any new contracts
const abiMap = {
BeefyClient: generated.beefyClientAbi,
AgentExecutor: generated.agentExecutorAbi,
Gateway: generated.gatewayAbi,
ServiceManager: generated.dataHavenServiceManagerAbi,
VetoableSlasher: generated.vetoableSlasherAbi,
RewardsRegistry: generated.rewardsRegistryAbi,
Agent: generated.agentAbi,
DelegationManager: generated.delegationManagerAbi,
StrategyManager: generated.strategyManagerAbi,
AVSDirectory: generated.avsDirectoryAbi,
EigenPodManager: generated.eigenPodManagerAbi,
EigenPodBeacon: generated.eigenPodAbi,
RewardsCoordinator: generated.rewardsCoordinatorAbi,
AllocationManager: generated.allocationManagerAbi,
PermissionController: generated.permissionControllerAbi,
ETHPOSDeposit: generated.iethposDepositAbi,
BaseStrategyImplementation: generated.strategyBaseTvlLimitsAbi,
DeployedStrategies: erc20Abi
} as const satisfies Record<keyof Omit<AnvilDeployments, "network">, Abi>;
type ContractName = keyof typeof abiMap;
type AbiFor<C extends ContractName> = (typeof abiMap)[C];
export type ContractInstance<C extends ContractName> = Awaited<
ReturnType<typeof getContractInstance<C>>
>;
// TODO: make this work with DeployedStrategies
export const getContractInstance = async <C extends ContractName>(
contract: C,
viemClient?: ViemClientInterface
) => {
const deployments = await parseDeploymentsFile();
const contractAddress = deployments[contract];
logger.debug(`Contract ${contract} deployed to ${contractAddress}`);
const client = viemClient ?? (await createDefaultClient());
invariant(
typeof contractAddress === "string" && isAddress(contractAddress),
`Contract address for ${contract} is not a valid address`
);
const abi: AbiFor<C> = abiMap[contract];
invariant(abi, `ABI for contract ${contract} not found`);
return getContract({
address: contractAddress,
abi,
client
});
};

View file

@ -7,3 +7,4 @@ export * from "./rpc";
export * from "./viem";
export * from "./kurtosis";
export * from "./parser";
export * from "./contracts";

View file

@ -40,7 +40,7 @@ export const timeoutConfirm = createPrompt<boolean, TimeoutConfirmConfig>((cfg,
clearInterval(id);
done(cfg.default ?? true);
}
}, 10);
}, 200);
return () => clearInterval(id);
}, []);
@ -71,7 +71,10 @@ ${defaultBadge} ${input}`;
)
);
return `${border}\n${hint}\n${main}\n${border}`;
return `${border}
${hint}
${main}
${border}`;
});
export const confirmWithTimeout = (

View file

@ -8,7 +8,7 @@ import {
defineChain,
publicActions
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
export const createChainConfig = async () =>
defineChain({
@ -45,4 +45,8 @@ export const createDefaultClient = async () =>
transport: http()
}).extend(publicActions);
export interface ViemClientInterface extends WalletClient, PublicActions {}
// export interface ViemClientInterface extends WalletClient, PublicActions {}
export type ViemClientInterface = Awaited<ReturnType<typeof createDefaultClient>>;
export const generateRandomAccount = () => privateKeyToAccount(generatePrivateKey());

33
test/wagmi.config.ts Normal file
View file

@ -0,0 +1,33 @@
import { defineConfig } from "@wagmi/cli";
import { actions, foundry } from "@wagmi/cli/plugins";
export default defineConfig({
out: "contract-bindings/generated.ts",
plugins: [
actions(), // TODO: Investigate why the actions() plugin is not functioning as expected. Refer to the @wagmi/cli documentation for potential solutions.
foundry({
project: "../contracts",
include: [
"BeefyClient.sol/**",
"AgentExecutor.sol/**",
"Gateway.sol/**",
"TransparentUpgradeableProxy.sol/**",
"VetoableSlasher.sol/**",
"RewardsRegistry.sol/**",
"Agent.sol/**",
"StrategyManager.sol/**",
"AVSDirectory.sol/**",
"DataHavenServiceManager.sol/**",
"EigenPodManager.sol/**",
"EigenPod.sol/**",
"UpgradeableBeacon.sol/**",
"RewardsCoordinator.sol/**",
"AllocationManager.sol/**",
"DelegationManager.sol/**",
"PermissionController.sol/**",
"IETHPOSDeposit.sol/**",
"StrategyBaseTVLLimits.sol/**"
]
})
]
});