@coinbase/onchaintestkit
Version:
End-to-end testing toolkit for blockchain applications, powered by Playwright
454 lines (453 loc) • 16.4 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.LocalNodeManager = void 0;
const child_process_1 = require("child_process");
const net = __importStar(require("net"));
const ethers_1 = require("ethers");
/**
* LocalNodeManager provides a comprehensive interface for managing a local Anvil Ethereum node.
* It handles node lifecycle, state management, and provides methods for manipulating blockchain state.
*
* Features:
* - Node lifecycle management (start/stop)
* - Chain state manipulation (snapshots, revert, reset)
* - Time control (time travel, block mining)
* - Account management (balance setting, impersonation)
* - Network configuration (gas prices, chain ID)
* - Automatic port allocation for parallel test execution
*
* @example
* ```typescript
* const node = new LocalNodeManager({
* chainId: 1337,
* forkUrl: process.env.MAINNET_RPC_URL
* });
*
* await node.start();
* const snapshot = await node.snapshot();
* // Run tests...
* await node.revert(snapshot);
* await node.stop();
* ```
*/
class LocalNodeManager {
/**
* Creates a new LocalNodeManager instance with the specified configuration
* @param config - Node configuration options
*/
constructor(config = {}) {
this.process = null;
this.provider = null;
this.allocatedPort = null;
this.config = {
// default to base sepolia
chainId: 84532,
blockTime: 0,
// Use port range if specified, otherwise use default range
portRange: config.portRange ?? LocalNodeManager.DEFAULT_PORT_RANGE,
...config,
};
}
/**
* Checks if a port is available for use
* @param port - The port to check
* @returns Promise that resolves to true if port is available, false otherwise
* @private
*/
async isPortAvailable(port) {
// Store the result for logging purposes if needed
let result = false;
return new Promise(resolve => {
const server = net.createServer();
// Handle connection errors (likely port in use)
server.once("error", err => {
const e = err;
if (e.code === "EADDRINUSE") {
server.close();
result = false;
resolve(result);
}
});
// Handle successful binding (port is available)
server.once("listening", () => {
server.close();
result = true;
resolve(result);
});
// Try to bind to the port
server.listen(port, "127.0.0.1");
});
}
/**
* Finds an available port within the configured range
* @returns Promise that resolves to an available port number
* @private
*/
async findAvailablePort() {
// If a specific port was requested, check if it's available
if (this.config.port) {
const isAvailable = await this.isPortAvailable(this.config.port);
if (isAvailable) {
return this.config.port;
}
console.warn(`Port ${this.config.port} is already in use. Will try to find another port.`);
}
// Find an available port in the range
const [minPort, maxPort] = this.config.portRange ?? LocalNodeManager.DEFAULT_PORT_RANGE;
const range = maxPort - minPort + 1;
// With a large range, we can simply try random ports until we find an available one
// Try up to 20 times to find an available port (should be plenty with a large range)
const maxAttempts = 20;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const randomPort = minPort + Math.floor(Math.random() * range);
const isAvailable = await this.isPortAvailable(randomPort);
if (isAvailable) {
return randomPort;
}
console.log(`Port ${randomPort} is already in use. Trying another port.`);
}
throw new Error(`No available ports found in range ${minPort}-${maxPort} after ${maxAttempts} attempts`);
}
/**
* Starts the Anvil node with the configured options
* @throws Error if node is already running or no available ports
*/
async start() {
if (this.process) {
throw new Error("Node is already running");
}
let retries = 0;
let started = false;
while (!started && retries < LocalNodeManager.MAX_PORT_ALLOCATION_RETRIES) {
try {
// Allocate port before starting the node
this.allocatedPort = await this.findAvailablePort();
const args = this.buildAnvilArgs();
this.process = (0, child_process_1.spawn)("anvil", args, {
stdio: ["ignore", "pipe", "pipe"],
});
// Handle process errors
this.process.on("error", error => {
console.error(`[Anvil:${this.allocatedPort}] process error:`, error);
this.cleanup();
});
// Add debug listeners
this.process.stdout?.on("data", (data) => {
console.log(`[Anvil:${this.allocatedPort}] stdout: ${data.toString().trim()}`);
});
this.process.stderr?.on("data", (data) => {
console.error(`[Anvil:${this.allocatedPort}] stderr: ${data.toString().trim()}`);
});
// Handle process exit
this.process.on("exit", code => {
if (code !== 0) {
console.error(`[Anvil:${this.allocatedPort}] process exited with code ${code}`);
}
this.cleanup();
});
// Wait for node to be ready
await this.waitForNodeReady();
// Initialize provider
this.provider = new ethers_1.ethers.providers.JsonRpcProvider(`http://localhost:${this.allocatedPort}`);
started = true;
}
catch (error) {
retries++;
console.warn(`Failed to start Anvil node (attempt ${retries}/${LocalNodeManager.MAX_PORT_ALLOCATION_RETRIES}):`, error);
// Clean up if the node process was created but failed to start properly
this.cleanup();
// Wait before retrying to avoid race conditions
if (retries < LocalNodeManager.MAX_PORT_ALLOCATION_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
if (!started) {
throw new Error(`Failed to start Anvil node after ${LocalNodeManager.MAX_PORT_ALLOCATION_RETRIES} attempts`);
}
}
/**
* Stops the running Anvil node and cleans up resources
*/
async stop() {
this.cleanup();
}
/**
* Internal cleanup method to ensure proper resource cleanup
* @private
*/
cleanup() {
if (this.process) {
this.process.kill();
this.process = null;
}
this.provider = null;
this.allocatedPort = null;
}
/**
* Get the allocated port for this node instance
* @returns Port number or null if node is not started
*/
getPort() {
return this.allocatedPort;
}
/**
* Port getter for easier access
* @returns The allocated port number or -1 if not started
*/
get port() {
return this.allocatedPort ?? -1;
}
get rpcUrl() {
return `http://localhost:${this.allocatedPort}`;
}
// Chain State Management
/**
* Takes a snapshot of the current chain state
* @returns Snapshot ID that can be used with revert()
*/
async snapshot() {
return this.send("anvil_snapshot", []);
}
/**
* Reverts the chain state to a previous snapshot
* @param snapshotId - ID returned from snapshot()
*/
async revert(snapshotId) {
await this.send("anvil_revert", [snapshotId]);
}
/**
* Resets the chain state to initial state or specified fork block
* @param forkBlock - Optional block number to reset to when in fork mode
*/
async reset(forkBlock) {
await this.send("anvil_reset", forkBlock
? [{ forking: { blockNumber: `0x${forkBlock.toString(16)}` } }]
: []);
}
// Block Management
/**
* Mines a specified number of blocks
* @param blocks - Number of blocks to mine (default: 1)
*/
async mine(blocks = 1) {
await this.send("anvil_mine", [blocks]);
}
/**
* Enables or disables automatic block mining
* @param enabled - Whether to enable auto-mining
*/
async setAutomine(enabled) {
await this.send("anvil_setAutomine", [enabled]);
}
// Time Management
/**
* Sets the timestamp for the next block
* @param timestamp - Unix timestamp in seconds
*/
async setNextBlockTimestamp(timestamp) {
await this.send("anvil_setNextBlockTimestamp", [timestamp]);
}
/**
* Increases chain time by specified seconds
* @param seconds - Number of seconds to move forward
*/
async increaseTime(seconds) {
await this.send("anvil_increaseTime", [seconds]);
}
/**
* Sets absolute chain time
* @param timestamp - Unix timestamp in seconds
*/
async setTime(timestamp) {
await this.send("anvil_setTime", [timestamp]);
}
// Account Management
/**
* Gets list of available accounts
* @returns Array of account addresses
*/
async getAccounts() {
return this.send("eth_accounts", []);
}
/**
* Sets balance for specified address
* @param address - Account address
* @param balance - New balance in wei
*/
async setBalance(address, balance) {
await this.send("anvil_setBalance", [address, `0x${balance.toString(16)}`]);
}
/**
* Sets nonce for specified address
* @param address - Account address
* @param nonce - New nonce value
*/
async setNonce(address, nonce) {
await this.send("anvil_setNonce", [address, nonce]);
}
/**
* Sets contract code at specified address
* @param address - Contract address
* @param code - Contract bytecode
*/
async setCode(address, code) {
await this.send("anvil_setCode", [address, code]);
}
// Contract State Management
/**
* Sets storage value at specified slot
* @param address - Contract address
* @param slot - Storage slot
* @param value - New value
*/
async setStorageAt(address, slot, value) {
await this.send("anvil_setStorageAt", [address, slot, value]);
}
// Fee Management
/**
* Sets base fee for next block (EIP-1559)
* @param fee - Base fee in wei
*/
async setNextBlockBaseFeePerGas(fee) {
await this.send("anvil_setNextBlockBaseFeePerGas", [
`0x${fee.toString(16)}`,
]);
}
/**
* Sets minimum gas price
* @param price - Min gas price in wei
*/
async setMinGasPrice(price) {
await this.send("anvil_setMinGasPrice", [`0x${price.toString(16)}`]);
}
// Chain Management
/**
* Sets chain ID
* @param chainId - New chain ID
*/
async setChainId(chainId) {
await this.send("anvil_setChainId", [chainId]);
}
// Impersonation
/**
* Enables impersonation of specified account
* @param address - Address to impersonate
*/
async impersonateAccount(address) {
await this.send("anvil_impersonateAccount", [address]);
}
/**
* Disables impersonation of specified account
* @param address - Address to stop impersonating
*/
async stopImpersonatingAccount(address) {
await this.send("anvil_stopImpersonatingAccount", [address]);
}
/**
* Builds command line arguments for Anvil based on configuration
* @returns Array of command line arguments
* @private
*/
buildAnvilArgs() {
const args = [];
// Use allocated port instead of config.port
if (this.allocatedPort)
args.push("--port", this.allocatedPort.toString());
if (this.config.chainId)
args.push("--chain-id", this.config.chainId.toString());
if (this.config.blockTime)
args.push("--block-time", this.config.blockTime.toString());
if (this.config.forkUrl)
args.push("--fork-url", this.config.forkUrl);
if (this.config.forkBlockNumber)
args.push("--fork-block-number", this.config.forkBlockNumber.toString());
if (this.config.noMining)
args.push("--no-mining");
if (this.config.hardfork)
args.push("--hardfork", this.config.hardfork);
if (this.config.mnemonic)
args.push("--mnemonic", this.config.mnemonic);
return args;
}
/**
* Waits for Anvil node to be ready to accept connections
* @returns Promise that resolves when node is ready
* @private
*/
async waitForNodeReady() {
return new Promise((resolve, reject) => {
if (!this.process) {
reject(new Error("Node process not started"));
return;
}
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for node to start"));
}, 30000);
// We already have listeners for stdout/stderr in start(),
// so we're only setting up a variable to track if we've seen the ready message
let isReady = false;
const checkReadyMessage = (data) => {
if (data.toString().includes("Listening on") && !isReady) {
isReady = true;
clearTimeout(timeout);
resolve();
}
};
// Add one-time listener for the ready message
this.process.stdout?.on("data", checkReadyMessage);
});
}
/**
* Sends JSON-RPC request to Anvil node
* @param method - RPC method name
* @param params - RPC method parameters
* @returns Promise that resolves with RPC response
* @private
*/
async send(method, params) {
if (!this.provider) {
throw new Error("Node not started");
}
// BigInts must be converted to hex strings for Anvil's RPC methods
return this.provider.send(method, params);
}
}
exports.LocalNodeManager = LocalNodeManager;
// Using a much larger port range to minimize allocation conflicts
LocalNodeManager.DEFAULT_PORT_RANGE = [10000, 20000];
// Maximum retries for port allocation
LocalNodeManager.MAX_PORT_ALLOCATION_RETRIES = 5;