Octomind
Same model as opencode, same endpoint, same prices — octomind solved 24/25 to its 19/25, at half the cost. Open-source agent runtime: one binary, any model, runs unattended in CI. Apache 2.0.
翻译准备中
Compile, test, deploy and verify Solidity in seconds: one binary, no setup, Foundry projects as they are
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗Compile, test, deploy, verify and upgrade EVM smart contracts with one ~9 MB binary. There's no config, no npm install and no forge install, your Foundry project works as it is, and the loop you run all day takes seconds instead of minutes.
curl -fsSL https://getplain.org/install | sh # macOS and Linux
plain init # src/Counter.sol + test/Counter.t.sol
plain test --watch # compile + run every test, again on every save
plain account new deployer # a deployer key, encrypted with your password
plain deploy Counter --network base-sepolia --account deployer --broadcast # deploy, then verify on explorers
plain # or do all of it from a menu
plain is also on Homebrew (brew install backmeupplz/tap/plain), npm, mise, apt, dnf, the AUR, Nix and crates.io, as the install guide shows, and cargo install --git https://github.com/backmeupplz/plain builds it from source. The docs walk through new and existing projects, testing and deploying.
| On real projects (details) | Foundry | Hardhat 3 | plain |
|---|---|---|---|
| Edit a test, rerun the suite (300 tests, via-IR) | 56 s | 61 s | 0.99 s |
| Edit a contract, rerun the suite | 103 s | 109 s | 2.1 s |
| Build from scratch | 116 s | 115 s | 15 s |
| Deploy 40 transactions over an RPC 80 ms away | 8.0 s | — | 2.0 s |
src/ (or contracts/), test/ and script/, picks and downloads the right solc from your pragmas (it reuses Foundry's solc cache if present), and compiles in parallel jobs. Results are cached by content hash, per project and in ~/.plain/cache, which every checkout shares (git worktrees, fresh clones).*.t.sol, setUp, test*, fuzzing, invariants, cheatcodes) on revm, in parallel. It also runs test/**/*.test.ts files against a fresh in-process chain per file. A suite whose compiled code and inputs haven't changed reuses its last results.vm.startBroadcast) or a single contract, optionally behind an OpenZeppelin proxy. It always simulates first, against a fork of the target network, then signs and sends when you add --broadcast, and verifies every contract it created on Sourcify, Etherscan and the chain's Blockscout. Keys stay in encrypted keystores (plain account, Foundry's work too).plain upgrade checks that the new implementation keeps the proxy's storage layout, then deploys it and points the proxy at it.plain fmt lays out src/, test/ and script/ exactly as forge fmt 1.7.1 does, byte for byte and with foundry.toml's [fmt] settings, in about a third of its time; plain fmt --check is the CI gate.plain with no arguments opens a menu for all of the above that prints every command it runs.base, base-sepolia, linea, 8453, ...), or a URL.forge-std is built into the binary, so import {Test} from "forge-std/Test.sol"; works without installing anything. lib/ dependencies, remappings.txt and node_modules (@openzeppelin/...) resolve automatically.foundry.toml is read, including solc version, optimizer, via_ir, remappings, compiler profiles and restrictions, fuzz/invariant runs, isolate, gas_limit, libraries and rpc_endpoints.plain # in a terminal: a menu for everything below
plain build [files...] [--force] [--check] [--sizes] [--skip test|script] [--out <dir>] [--extra-output storageLayout]
plain test [filter] [-v] [--mt name] [--mc name] [--mp glob] [--fork <network|url>] [--fuzz-runs N] [--seed N|random] [--isolate] [--gas-limit N] [--threads N] [--rerun] [--force] [--watch]
plain deploy <Contract|path/Script.s.sol> [args...] [--network <name|id|url>] [--account <name>] [--broadcast] [--confirm] [--slow] [--proxy] [--no-verify]
plain deploy --resume --network <name|id|url>
plain script <path/Script.s.sol[:Contract]> --rpc-url <url|name> [--chain <id>] [--broadcast] [--slow] # forge script's flags; writes forge's dry-run journal
plain upgrade <proxy> [Contract] [--call <function> args...] --network <name|id|url> [--account <name>] [--broadcast] [--slow]
plain fmt [paths...] [--check] # forge fmt's output; - formats stdin
plain inspect <Contract> storage-layout [--json]
plain verify [--network <name|id|url>] [name|address...] [--export]
plain account [new <name> | import <name>]
plain node [--port 8545] [--fork <network|url>] [--host ip] [--init genesis.json] [--chain-id N] [--gas-limit N] [--base-fee N] [--silent]
plain init
plain <anything> # runs `plain-<anything>` from PATH (plugins)
filter matches suite paths (test/Vault.t.sol:VaultTest) and test names, and only the test files it can match get compiled. A filter that matches nothing, or an unknown option, is an error. Exit code is non-zero on any failure.
The fuzz seed is fixed (0x5eed) unless you set one with --seed N, PLAIN_SEED or fuzz.seed in foundry.toml (in that order), so a fuzz or invariant test that passed keeps passing until the code changes. To have CI try new inputs on every run, as forge does with no seed set, use plain test --seed random (or PLAIN_SEED=random), which prints the seed it drew, or a seed that changes with each run such as --seed $GITHUB_RUN_NUMBER. A fuzz or invariant failure prints the seed it ran with, so plain test --seed <that seed> --mt <test> replays it. A new seed reruns only the fuzz and invariant tests: the others keep their cached results.
plain build --check compiles every contract again from the standard JSON input explorers get and fails unless the bytecode is identical. Given files, plain build builds only those and what they import, and --skip test or --skip script leaves those out. --sizes prints forge's table of runtime and init code sizes and fails when a contract is over the EIP-170 or EIP-3860 limit. --out <dir> (or FOUNDRY_OUT) also writes forge's artifact layout there (<dir>/<File.sol>/<Name>.json, <Name>.<profile>.json for other compiler profiles) for tools that read forge's out/: the ABI in forge's order, both bytecodes with their link and immutable references, and methodIdentifiers, with each contract's storage layout under --extra-output storageLayout. Contracts in src/ and libraries are byte for byte forge's; test and script contracts are plain's own builds (see Benchmarks), and plain inspect <Contract> storage-layout prints a storage layout (--json for forge's JSON).
plain fmt is forge fmt's formatter (Foundry 1.7.1's, on the same solar parser), so its output is forge's for every [fmt] setting (line_length, tab_width, style, bracket_spacing, int_types, multiline_func_header, quote_style, number_underscore, hex_underscore, single_line_statement_blocks, override_spacing, wrap_comments, docs_style, ignore, contract_new_lines, sort_imports, namespace_import_style, pow_no_space, prefer_compact, single_line_imports) and // forgefmt: comments; a key forge doesn't know is an error. --check lists the files that would change and exits 1. On veydrift's 200 files plain fmt --check takes 47 ms against forge's 150 ms, on v4-core 16 ms against 40 ms.
plain node takes anvil's flags, so a script that starts anvil --host 127.0.0.1 --port N --init genesis.json --chain-id 8453 --gas-limit N --base-fee N --silent can start plain node instead: --init loads a geth-style genesis (each alloc account's balance, nonce, code and storage, its timestamp, and config.chainId), transactions get their own gas limit and pay the base fee, failed transactions with a gas limit are mined with status 0, and eth_estimateGas finds the lowest gas a call succeeds with, as anvil's does.
Semantics follow forge 1.x: expectRevert(bytes4) is an exact match (expectPartialRevert for selector-only), testFail* fails, invariants check the initial state and afterInvariant(), tests run under the configured evm_version (default: forge's osaka, compiled for the newest EVM the solc supports), FOUNDRY_PROFILE selects a profile. --isolate (or isolate = true) runs each call a test makes as a transaction of its own, as forge's does, and --gas-limit sets the gas each test and its block get. A file that compilation_restrictions rule out of the default settings compiles with the first of additional_compiler_profiles that fits, as in forge, and its tests deploy that profile's builds. vm.ffi needs ffi = true and file cheatcodes need fs_permissions, as in forge; test/**/*.test.ts runs only with typescript = true in plain.toml.
Test results are cached: a suite reruns only when its compiled bytecode, the test settings, the plain binary, or something it read (contracts it deployed with getCode, files, env vars) changed. Cached suites print (cached). Suites that call ffi, writeFile, unixTime or setEnv, and every --fork run, always rerun. --rerun runs every test again, and --force also recompiles.
Solidity: Foundry style, using forge-std:
import {Test, console} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
contract VaultTest is Test {
Vault vault;
address alice = makeAddr("alice");
function setUp() public { vault = new Vault(); deal(alice, 10 ether); }
function test_Deposit(uint96 amount) public { // parameters = fuzz test
vm.assume(amount <= 10 ether);
vm.prank(alice);
vault.deposit{value: amount}();
assertEq(vault.balanceOf(alice), amount);
}
function test_RevertWhen_Overdraw() public {
vm.expectRevert(abi.encodeWithSelector(Vault.Insufficient.selector, 0, 1));
vm.prank(alice);
vault.withdraw(1);
}
function invariant_Solvent() public view { // invariant: random calls to contracts made in setUp
assertGe(address(vault).balance, vault.totalDeposits());
}
}
Failures print the decoded reason (custom errors, panics, require strings), the fuzz counterexample, console.log output, and a short call trace that follows the path to the revert:
FAIL test_Deposit(uint96): OutOfFunds
args: 79228162514264337593543950335
trace:
VaultTest.test_Deposit(79228162514264337593543950335) ✗ reverted without reason
vm.prank(alice)
Vault.deposit() value: 79228162514264337593543950335 ✗ OutOfFunds
Supported cheatcodes: prank/startPrank, deal, warp, roll, fee, chainId, prevrandao, coinbase, store/load, etch, nonces, addr/sign/rememberKey, label, assume, skip (with a reason too), expectRevert (all forms), expectEmit, expectCall, mockCall, recordLogs/getRecordedLogs, snapshotState/revertToState, env*, setEnv, toString, parse*, getCode, readFile/writeFile, ffi, computeCreate*Address, broadcast cheatcodes, gas snapshots, forks inside a test (createFork, createSelectFork, selectFork, rollFork, activeFork, makePersistent), JSON (parseJson*, keyExists, serialize*, writeJson), record/accesses (which forge-std's deal and stdStorage use), cool/coolSlot, lastCallGas, pauseGasMetering/resumeGasMetering/resetGasMetering, removeFile, dumpState, rpcUrl, and every assert*. Anything else fails with unsupported cheatcode vm.<name>(...).
TypeScript: any test/**/*.test.ts runs with node --test (or bun test if it imports bun:test). Each file gets its own fresh chain, and files run in parallel. Two environment variables are set: PLAIN_RPC_URL (anvil/hardhat-compatible JSON-RPC, chain id 31337, the 10 default anvil accounts funded and unlocked) and PLAIN_ARTIFACTS (.plain/out/<Contract>.json, holding abi and bytecode). With viem:
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createTestClient, http, publicActions, walletActions } from "viem";
import { foundry } from "viem/chains";
const Counter = JSON.parse(readFileSync(`${process.env.PLAIN_ARTIFACTS}/Counter.json`, "utf8"));
const client = createTestClient({ chain: foundry, mode: "anvil", transport: http(process.env.PLAIN_RPC_URL) })
.extend(publicActions).extend(walletActions);
const [me] = await client.getAddresses();
test("increments", async () => {
const hash = await client.deployContract({ abi: Counter.abi, bytecode: Counter.bytecode, account: me });
const { contractAddress } = await client.waitForTransactionReceipt({ hash });
await client.writeContract({ address: contractAddress!, abi: Counter.abi, functionName: "increment", account: me });
assert.equal(await client.readContract({ address: contractAddress!, abi: Counter.abi, functionName: "number" }), 1n);
});
The chain supports evm_snapshot/evm_revert, evm_increaseTime, evm_mine/anvil_mine, anvil_setBalance/setCode/setStorageAt/setNonce, impersonation (every account is unlocked), eth_getLogs, and reverts with decoded custom errors.
plain account new deployer # once: a key encrypted with your password
export ETHERSCAN_API_KEY=... # optional: one key covers Etherscan's chains
plain deploy Counter 42 0xabc... --network sepolia --account deployer # simulate only
plain deploy script/Deploy.s.sol --network base --account deployer --broadcast # sends txs, verifies
Before signing, every contract the deploy creates is compiled again from the standard JSON input explorers will get, and the deploy stops unless the bytecode is identical. The deployer's next nonce must match the simulation's, with nothing of the deployer's waiting in a node's pool. Scripts see the deployer as msg.sender/tx.origin in run(); the simulation enforces EIP-170/3860 size limits; --confirm asks before sending; records of what landed are kept even when a later transaction fails.
The deployer key comes from --account <name> (a keystore in ~/.plain/keystores or Foundry's ~/.foundry/keystores, unlocked with its password when something is sent), else $PRIVATE_KEY. Local chains (chain id 31337) use the first dev account. The project's .env is read like forge reads it. A simulation also prints what the deployer holds and the most the deploy can cost.
Scripts are forge-compatible: run(), vm.startBroadcast() (signing with the deployer), vm.startBroadcast(pk), and new X{salt: s}(), which goes through the deterministic CREATE2 deployer. External libraries that the deployed contracts link against are deployed first, from the deployer's address, except those the config lists as on chain already: forge's libraries = ["src/L.sol:L:0x…"] links against that address instead, once plain has checked there's code there.
What --broadcast does:
.plain/deploys/<chain id>-resume.json until all of them are in. The first goes out alone, so a fee, balance or chain problem stops the deploy before anything else is in the mempool. A node on this machine gets the rest at once. A live chain gets at most 8 at a time from a sender, since public RPCs rate-limit (a rate-limited send is retried with backoff) and op-geth keeps only 16 of a sender's transactions in its pool, dropping the rest. A transaction that uses a contract an earlier one creates goes out only once that one is in with success, and the last one (a script's final upgradeAndCall, say) only once everything before it is. --slow sends one at a time, each after the last one's receipt, like forge's. A transaction's hash decides what happened to it: a send that errors is looked up by hash first, because some RPCs answer with an error and still take the transaction, only what was signed is ever sent again, and a transaction counts once the chain has exactly it (sender, nonce, target, input and value) with success. Sending stops at the first rejection, revert, mismatch or nonce taken by another transaction, and plain prints which nonces landed, which are still out and which weren't sent.deployments/<network>.json and verifies every contract created, including proxies and contracts created by other contracts. Each contract goes to every explorer at once: Sourcify (no key), Etherscan's v2 API when ETHERSCAN_API_KEY is set (foundry.toml [etherscan] keys work too), and the chain's Blockscout and Routescan explorers from chainlist. Each explorer gets the contract's own import closure as standard JSON input, the same sources and settings solc compiled it from, so it compiles no more than it has to. --no-verify skips this step.If a deploy stops part way (a rate limit that doesn't let up, a node that drops transactions, plain itself killed), plain deploy --resume --network base sends the rest exactly as it was signed, with no build, simulation or key. It first checks that nothing of the senders' is waiting in a pool and that every nonce the chain has used since is taken by the plan's own transaction, byte for byte; what landed meanwhile counts as in. Until the deploy is finished, a new --broadcast to that chain is refused, since its signed transactions could still land at the nonces a new deploy would use. After a transaction that failed, the rest can't be resumed, because it counted on that one.
What explorers need is kept in .plain/deploys/<chain id>.json, so plain verify --network base retries whatever didn't verify (an explorer was down, or the key was missing). It doesn't compile anything. To verify only some contracts, pass names or addresses. --export writes verify/<network>/<Contract>.json (the standard JSON input, libraries linked) and <Contract>.txt (address, compiler version, license, constructor arguments and the explorer's verify page) for verifying by hand.
Upgradeable contracts: plain deploy Box <initialize args> --proxy deploys the implementation and an OpenZeppelin proxy (ERC1967Proxy for UUPS contracts, else TransparentUpgradeableProxy) compiled from the project's @openzeppelin/contracts, calling initialize in the proxy's constructor. deployments/<network>.json gets Box (the proxy) and Box_Implementation. plain upgrade Box [BoxV2] [--call initializeV2 args...] reads the current implementation from the ERC-1967 slot, compares solc's storage layouts (variables must keep their slots and types, down into structs, arrays, enums and user-defined value types; renames, appends and __gap use are fine; ERC-7201 namespaces are checked the same way), then deploys the new implementation and calls upgradeToAndCall (or the ProxyAdmin's upgradeAndCall). With an Etherscan key, proxies are also marked as proxies there.
Networks: any chain on chainlist.org by name, short name or chain id (base, base-sepolia, arbitrum-sepolia, linea, monad, 8453), plus local (127.0.0.1:8545) and any URL. Common chains use their official RPC. For other chains, plain races chainlist's public RPCs that don't track users and takes the first one that answers with the right chain id. $<NAME>_RPC_URL picks the RPC for a name (for example BASE_RPC_URL), as does a plain.toml entry or foundry.toml's rpc_endpoints. plain never prints more of an RPC URL than its host, since keys live in the path or query, so a keyed RPC (Alchemy, your own node) is best set as BASE_RPC_URL and used as --network base:
[networks]
my-l2 = "https://rpc.my-l2.xyz"
staging = "${STAGING_RPC}"
plain.toml uses the same keys as foundry.toml's [profile.default], and either file works:
solc = "0.8.28" # default: newest version satisfying every pragma
optimizer = true # default: true (Foundry projects: on only if optimizer_runs is set, like forge)
optimizer_runs = 200
via_ir = false
evm_version = "cancun" # default: solc's default
remappings = ["@x/=lib/x/src/"]
src = "src" # default: src, or contracts if there is no src
[fuzz]
runs = 256
[invariant]
runs = 256
depth = 500
Per-test fuzz runs: /// forge-config: default.fuzz.runs = 10 above the function.
plain foo args... runs plain-foo args... from PATH, with PLAIN_ROOT and PLAIN_ARTIFACTS in its environment. Any language works.
Median wall times from hyperfine on an M1 Max (10 cores), 2026-10-17, same harness for every tool: forge 1.7.1, Hardhat 3.20.0, solc as pinned by each project. "Cold" means nothing compiled anywhere (plain's shared cache cleared too). All tests pass under plain: 303 in veydrift, 598 in v4-core, 568 in solmate.
Foundry / Hardhat / plain:
| veydrift (via-IR) | Uniswap v4-core (via-IR) | solmate | |
|---|---|---|---|
| cold build | 116s / 115s / 15s | 141s / 144s / 12s | 5.6s / 6.8s / 1.1s |
| no-op build | 0.18s / 0.25s / 0.02s | 0.24s / 0.27s / 0.02s | 0.15s / 0.21s / 0.01s |
| test, warm (plain reuses unchanged results) | 0.25s / 0.43s / 0.04s | 4.7s / 5.8s / 0.61s³ | 4.0s / 3.5s / 0.02s¹ |
test, warm, every test run (--rerun) | 0.25s / 0.43s / 0.10s | 4.7s / 5.8s / 3.2s | 4.0s / 3.5s / 1.6s¹ |
| cold test (compile + test) | 113s / 115s / 15s | 144s / 147s / 16s | 9.5s / 9.9s / 2.7s¹ |
| edit a test, run tests² | 56s / 61s / 0.99s | 24s / 26s / 1.8s | 4.6s / 4.2s / 0.07s |
| edit a src function, run tests² | 103s / 109s / 2.1s | 130s / 132s / 4.2s | 6.0s / 5.7s / 1.5s |
| new worktree/clone of built sources, run tests | cold test / cold test / 0.06s | cold test / cold test / 0.76s | cold test / cold test / 0.03s |
¹ forge 1.x and Hardhat 3 dropped testFail*, so their solmate test timings exclude those tests (290 tests). plain runs all 568, including the legacy testFail* ones.
² One agent iteration: add a statement to one function, then run the whole test command. Every run is a new edit, the first one right after a build. The test edits are in the largest test file (veydrift VeydriftGame.t.sol, 7,500 lines; v4-core PoolManager.t.sol; solmate ERC20.t.sol), the src edits in VeydriftGame.settleFirstPlanet, PoolManager.swap and ERC20.transfer.
³ v4-core's two ffi suites (they spawn npm per call) run every time.
Deploying veydrift's script/Deploy.s.sol (40 transactions, 74M gas) to an anvil with 2 s blocks, forge script / plain deploy (Hardhat has no forge-style scripts):
| local RPC | RPC 80 ms away⁴ | |
|---|---|---|
| warm (built) | 4.1s / 1.9s | 8.0s / 2.0s |
| cold (nothing compiled) | 30s / 5.8s | 35s / 6.1s |
⁴ Every answer delayed 80 ms by a local proxy, like a public RPC on another continent. Waiting for the block is about 1 s of plain's time. The anvil has a 300M block gas limit: at anvil's default 30M it drops queued transactions that don't fit the next block, which plain resends (11 s) and forge waits on forever (--slow: 69 s).
Cold builds also peak at about 0.7 GB of memory under plain (veydrift, v4-core), against 3.2 to 4.6 GB for Foundry and Hardhat.
Where the speed comes from (solc itself is unchanged: what gets deployed is byte-for-byte what solc produces):
new Vault(args) would make solc compile and optimize Vault again inside the test, once per test file (via-IR makes that the bulk of a build). plain compiles test files with each new X(...) replaced by a deploy of X's compiled artifact: the same init code, deployer, nonce and address. The arguments are still type-checked against X's constructor (through abi.encodeCall on a generated contract), so a wrong argument is the same compile error as before. If a rewritten file fails to compile, plain compiles the original and reports its errors.gasleft(), gas snapshots), which takes about a third of solc's time. Only gas spent inside the test contract itself differs; the code under test is compiled exactly as configured.ffi, unixTime, forks) always reruns. A new seed reruns only fuzz and invariant tests.plain test <filter> compiles only test files the filter can match. plain deploy compiles only the target and what it deploys.Gas snapshot files, coverage, verifying contracts plain didn't deploy, the vm.deployCode cheatcode (use vm.getCode + create), targetArtifacts/targetInterfaces (invariants using them fail rather than pass), OpenZeppelin 4 transparent proxies, and beacon proxies. Library linking only works for libraries under src//test//script/.
Because tests deploy contracts from artifacts (see Benchmarks), the gas a test reports for a new X(...) is a little higher than under forge, and the test contract's own bytecode differs (more so for tests you just edited, which compile without the optimizer until the file is re-split). Everything else is exactly solc's output: what plain deploy sends, and the artifacts in .plain/out (test and script contracts aren't written there).
[profile.default]setUp state.eth_estimateGas calls. Where contracts will land is assumed empty and checked in one batch afterwards, instead of one fetch per contract. To a node on this machine, transactions go out in one batch after the first (a live chain gets 8 at a time), and receipt polling asks for each sender's next receipt only. veydrift's 40-transaction deploy makes 13 round trips instead of 77. A block that passes without our pending transactions triggers a resend (some nodes drop queued transactions).