@polkadot-api/forklift
v0.2.6
Published
A tool for forking live Substrate/Polkadot-SDK chains locally, built natively on [polkadot-api](https://github.com/polkadot-api/polkadot-api).
Readme
forklift
A tool for forking live Substrate/Polkadot-SDK chains locally, built natively on polkadot-api.
Forklift is inspired by @acala-network/chopsticks, but it was built primarily for testing workflows that need multiple live branches of the chain, making it possible to simulate forks, reorgs, and pruned branches.
Features
- Multiple live branches of the chain, including competing forks, reorgs, and pruned branches
- Immutable merkle-trie-backed storage with structural sharing between blocks
- Relay / parachain wiring helpers for local XCM testing
- Native
polkadot-apiimplementation withoutpolkadot-js - Based on the new chainHead_v1 / archive_v1 JSON-RPC methods
- YAML-based CLI config for single-chain and multi-chain setups
Installation
pnpm i @polkadot-api/forkliftThen run:
pnpm forklift --helpCLI
Forklift can be started in two ways:
- Directly from a remote endpoint
- From a YAML config file
Direct mode
forklift <url> [options]Arguments:
| Argument | Description |
| -------- | --------------------------------- |
| url | WebSocket URL of the node to fork |
Options:
| Option | Description | Default |
| ------------------------- | ------------------------------------------------------------- | ---------------- |
| -b, --block <block> | Block number or block hash to fork from | latest finalized |
| -p, --port <port> | Preferred local WebSocket port | 3000 |
| -c, --config <file> | Load a YAML config instead of using direct mode | |
| -l, --log-level <level> | Log level: trace, debug, info, warn, error, fatal | info |
Examples:
# Fork the latest finalized block
forklift wss://rpc.polkadot.io
# Fork a specific block number
forklift wss://rpc.polkadot.io --block 22000000
# Fork a specific block hash
forklift wss://rpc.polkadot.io --block 0xabc123...
# Prefer a specific local port
forklift wss://rpc.polkadot.io --port 9000The forklift CLI exposes a JSON-RPC WebSocket endpoint. In direct mode that is typically:
ws://localhost:3000If the requested port is already in use, forklift will try the next free port.
YAML Config
For anything beyond a single fork, the YAML config is the intended interface.
forklift --config forklift.ymlThe config supports either:
- a single chain at the root level
- multiple named chains under
chains:
Single-chain config
endpoint: wss://rpc.polkadot.io
block: 22000000
port: 3000
options:
buildBlockMode:
timer: 100
finalizeMode:
timer: 2000
storage:
- key: 0x1234567890
value: nullMulti-chain config
chains:
relay:
endpoint: wss://rpc.polkadot.io
port: 3000
assetHub:
endpoint: wss://sys.ibp.network/asset-hub-polkadot
port: 3001
parachainOf: relay
bridgeHub:
endpoint: wss://sys.ibp.network/bridge-hub-polkadot
port: 3002
parachainOf: relayIn multi-chain mode:
- each entry under
chains:starts its own local fork parachainOf: <name>declares that a chain should be attached to another local chain as its relay- chains that share the same relay are also attached to each other as siblings
That makes the config suitable for relay/parachain and parachain/parachain XCM testing setups.
Config Fields
Each chain config supports the following fields:
| Field | Type | Description |
| ------------- | -------------------- | --------------------------------------------------- |
| endpoint | string \| string[] | Remote WebSocket endpoint or endpoints to fork from |
| block | number \| string | Optional block number or block hash to fork from |
| port | number | Preferred local WebSocket port |
| parachainOf | string | Name of the relay chain in a multi-chain config |
| options | object | Forklift runtime options |
| storage | array | Storage overrides applied after startup |
options
options maps closely to the programmatic ForkliftOptions.
options:
disableOnIdle: false
buildBlockMode:
timer: 100
finalizeMode:
timer: 2000Supported values:
disableOnIdle: booleanDisableson_idlehooks during block production. Some runtimes might perform actions that take a long time as they perform multiple serial storage queries. Setting this option totruedisables that hook, which can increase the speed blocks can be produced.buildBlockModeControls when new blocks are built after transactions arrive.Manual mode:
buildBlockMode: manualTimer mode:
buildBlockMode: timer: 100finalizeModeControls when built blocks are finalized.Manual mode:
finalizeMode: manualTimer mode:
finalizeMode: timer: 2000
Notes:
manualmeans forklift only changes state when you explicitly drive it{ timer: 0 }is allowed and means immediate scheduling- if
portis omitted, forklift will choose a free port automatically
Storage Overrides
The storage section is applied after the local server has started and the initial block is available.
Forklift supports two storage override forms.
Raw form
Use raw SCALE-encoded keys and values directly:
storage:
- key: 0x1234...
value: 0xabcd...
- key: 0x5678...
value: nullUse null to delete or clear a storage entry.
Decoded form
Use pallet / storage names and let the CLI encode the key and value from metadata:
storage:
- pallet: System
entry: Account
key:
- 14GjNs7Lw7nVbJrL8aL8m8m4vY2mQ2L9mQf8u2YpK9nQx7aD
value:
providers: 1
consumers: 0
sufficients: 0
data:
free: 100_0_000_000_000n
reserved: 0n
frozen: 0n
flags: 170141183460469231731687303715884105728nNotes:
keymust be an array in decoded form, even if the storage entry takes a single key- big integers can be written as strings ending in
n, for example1000000000000n - underscores are accepted in numeric strings for readability
- if a storage item, key, or value cannot be encoded against the chain metadata, forklift logs the error and skips that override
Programmatic API
You can also create a chain from code:
import { forklift, wsSource } from "@polkadot-api/forklift";
import { Enum } from "polkadot-api";
const polkadot = forklift(
wsSource("wss://rpc.polkadot.io", {
atBlock: 22000000,
}),
{
buildBlockMode: Enum("timer", 100),
finalizeMode: Enum("timer", 2000),
disableOnIdle: false,
}
);The forklift instance then has a property serve which is a JsonRpcProvider - This is an unopinionated interface that serves JSON-RPC connections, and can be plugged directly into polkadot-api:
import { forklift } from "@polkadot-api/forklift";
import { createClient } from "polkadot-api";
const polkadot = forklift(/* … */);
const client = createClient(polkadot.serve);Or, given it's a simple interface, it's simple to expose that to a WS. For instance, using bun:
import { forklift } from "@polkadot-api/forklift";
const polkadot = forklift(/* … */);
Bun.serve({
fetch(req, server) {
// Al WS connections start with a HTTP request, we tell bun to upgrade the connection to a WS
const success = server.upgrade(req, { data: {} });
if (success) {
return undefined;
}
// handle HTTP request normally
return new Response("Nothing to see here, move along");
},
websocket: {
data: {} as any,
open(ws) {
// When the WS opens we call the JsonRpcProvider to open a connection, and wire up incoming messages from forklift to send them out to the WS
ws.data.connection = forklift.serve((msg) =>
ws.send(JSON.stringify(msg))
);
},
close(ws) {
// When it closes we just close the connection
ws.data.connection.disconnect();
},
async message(ws, message) {
// When we receive a message we just pass it down to forklift
ws.data.connection.send(JSON.parse(message as string));
},
},
});Forklift interface
interface Forklift {
serve: JsonRpcProvider;
newBlock(opts?: Partial<NewBlockOptions>): Promise<HexString>;
changeBest(hash: HexString): Promise<void>;
changeFinalized(hash: HexString): Promise<void>;
setStorage(
hash: HexString,
changes: Record<string, Uint8Array>
): Promise<void>;
getStorageDiff(
hash: HexString,
baseHash?: HexString
): Promise<
Record<string, { value: Uint8Array | null; prev?: Uint8Array | null }>
>;
changeOptions(opts: Partial<ForkliftOptions>): void;
destroy(): void;
}Block production modes
buildBlockMode controls when new blocks are produced:
Enum("manual"): only explicitnewBlock()calls produce blocksEnum("timer", ms): automatically produce a block after a transaction arrives
finalizeMode controls when blocks are finalized:
Enum("manual"): only explicitchangeFinalized()calls finalize blocksEnum("timer", ms): automatically finalize a block after it is built
Producing forks
Pass a parent hash to branch from any existing block:
const base = await f.newBlock();
const forkA = await f.newBlock({ parent: base, type: "fork" });
const forkB = await f.newBlock({ parent: base, type: "fork" });Storage overrides from code
await f.setStorage(hash, {
"0x...key": new Uint8Array([...value]),
});
const diff = await f.getStorageDiff(hash);JSON-RPC Surface
Forklift serves a WebSocket JSON-RPC endpoint and currently includes methods in these groups:
archive_v1_*chainHead_v1_*chainSpec_v1_*transaction_v1_*dev_*forklift_xcm_*
Acknowledgements
Forklift is heavily inspired by @acala-network/chopsticks and reuses its WASM executor package, @acala-network/chopsticks-executor, for local runtime execution.
