> ## Documentation Index
> Fetch the complete documentation index at: https://docs.eco.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing programs

> Run SauceScript programs as cooks on an empty or forked local chain from Node tests with @eco-incorp/sauce/testing, recorded once and replayed offline.

`@eco-incorp/sauce/testing` runs your programs from a test, as cooks on a chain only the test can see. `local()` starts an empty chain; `fork()` starts a copy of a real chain at a pinned block, recorded to a file once and replayed from it with no network. Each chain has the V12 release deployed and ten test accounts, each with a Pot, and the kit gives you calls to fund accounts, run or simulate a cook, read balances and check what moved.

The kit runs in Node only. It reads no environment variable: the endpoint, the block and the state file are arguments. Its functions are plain async functions, so any test runner works; the examples use `node:test`.

## Install

```bash theme={null}
npm install -D @eco-incorp/sauce viem
```

* **EVM chains** run on Foundry's `anvil`, which must be on `PATH` or named by the `anvil` option. Install Foundry as the [Foundry installation guide](https://getfoundry.sh/introduction/installation) describes:

  ```bash theme={null}
  curl -L https://foundry.paradigm.xyz | bash
  foundryup
  ```

* **Solana** runs in LiteSVM, inside the test's process. Install `litesvm` in your project at the version the package pins. Starting a Solana chain without it throws an error that names the install command:

  ```text theme={null}
  the testing kit runs Solana in LiteSVM, which this project does not have: npm i -D litesvm@<version>
  ```

## Test a program that calls nothing

A program that calls no protocol and moves no token needs no copy of a real chain. `local()` starts an empty one with no network.

```ts double.sauce.ts theme={null}
// Returns twice `a`, and refuses zero.
function main(a: Uint256): Uint256 {
  require(a > 0, "a is zero");
  return a * 2;
}
```

```ts double.test.ts theme={null}
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import { CookReverted, local, type TestChain } from "@eco-incorp/sauce/testing";

const DOUBLE = new URL("./double.sauce.ts", import.meta.url);

describe("double.sauce.ts", () => {
  let chain: TestChain;
  before(async () => {
    chain = await local();
  });
  after(() => chain.stop());

  it("returns twice its input", async () => {
    const result = await chain.run(DOUBLE, { args: [21n] });
    assert.equal(result.returned, 42n);
  });

  it("refuses zero", async () => {
    await assert.rejects(
      chain.simulate(DOUBLE, { args: [0n] }),
      (error) => error instanceof CookReverted && error.reason === "a is zero",
    );
  });
});
```

```bash theme={null}
node --test --test-timeout=120000 double.test.ts
```

```text theme={null}
▶ double.sauce.ts
  ✔ returns twice its input (70.601667ms)
  ✔ refuses zero (6.946375ms)
✔ double.sauce.ts (349.582292ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
```

`chain.run` sent a cook from test account 0's Pot and returned what `main` returned. `chain.simulate` ran the same cook without sending it; the `require` failed, so it threw `CookReverted` with the `require`'s message as `reason`. Starting a chain takes seconds, so give each test and hook a timeout: `node:test` has no default limit.

## Test against a copy of a chain

A program that reads or moves a token needs a chain where the token exists. This test forks Base at a pinned block and runs a payment program on it:

```ts pay.sauce.ts theme={null}
// Pays `amount` of USDC from the Pot to `to`.
function main(to: Address, amount: Uint256) {
  require(pot.USDC >= amount, "not enough USDC");
  USDC.transfer(to, amount);
}
```

```ts pay.test.ts theme={null}
import assert from "node:assert/strict";
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
import { CookReverted, fork, type TestChain } from "@eco-incorp/sauce/testing";
import { base as baseChain } from "viem/chains";

const PAY = new URL("./pay.sauce.ts", import.meta.url);

describe("pay.sauce.ts on Base", () => {
  let base: TestChain;
  before(async () => {
    base = await fork("base", {
      block: 52_965_407,
      state: "test/state/pay-base.json.gz",
      rpcUrl: baseChain.rpcUrls.default.http[0], // read only while the state file does not exist
    });
  });
  after(() => base.stop());
  beforeEach(() => base.snapshot());
  afterEach(() => base.revert());

  it("pays USDC from the Pot", async () => {
    const pot = base.account(0).pot;
    const recipient = base.account(1).address;
    await base.fund(pot, { USDC: "5" });

    const result = await base.run(PAY, { args: [recipient, 1_000_000n] });

    assert.equal(result.delta(recipient, "USDC"), 1_000_000n);
    assert.equal(result.delta(pot, "USDC"), -1_000_000n);
    console.log("mode:", base.mode, "gas:", result.gasUsed, "transfers:", result.transfers.length);
  });

  it("refuses more than the Pot holds", async () => {
    const recipient = base.account(1).address;
    await assert.rejects(
      base.simulate(PAY, { args: [recipient, 1_000_000n] }),
      (error) => error instanceof CookReverted && error.reason === "not enough USDC",
    );
  });
});
```

The first run records. The state file does not exist yet, so the fork reads Base through `rpcUrl`, here viem's default Base endpoint, and `stop()` writes every account and storage slot the tests read to `test/state/pay-base.json.gz`:

```bash theme={null}
node --test --test-timeout=120000 pay.test.ts
```

```text theme={null}
mode: record gas: 97962n transfers: 1
▶ pay.sauce.ts on Base
  ✔ pays USDC from the Pot (4427.751125ms)
  ✔ refuses more than the Pot holds (394.90775ms)
✔ pay.sauce.ts on Base (11329.363458ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
```

A public endpoint serves the state of recent blocks only, so record soon after you choose the block; an older block needs an archive node. Every later run replays the file and reads no endpoint:

```bash theme={null}
node --test --test-timeout=120000 pay.test.ts
```

```text theme={null}
mode: replay gas: 97962n transfers: 1
▶ pay.sauce.ts on Base
  ✔ pays USDC from the Pot (79.301417ms)
  ✔ refuses more than the Pot holds (8.02ms)
✔ pay.sauce.ts on Base (369.552875ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
```

The replayed cook used the same gas as the recorded one. Commit the state file beside the test; CI then runs it with `anvil` installed and no endpoint or secret.

* `base.fund(pot, { USDC: "5" })` gave account 0's Pot 5 USDC. A string is whole tokens; a `bigint` is base units.
* `result.delta(holder, "USDC")` is how much a holder's balance changed during the cook, in base units: 1,000,000 is 1 USDC.
* `snapshot()` before each test and `revert()` after it return the chain to the same state, so the second test's Pot holds no USDC.

## fork

Starts a copy of a chain at a pinned point: on an EVM chain, `anvil` at `options.block`; on Solana, LiteSVM. The release's engine, Router and Kitchen are ready, ten test accounts exist, and account 0's Pot is deployed.

### Import

```ts theme={null}
import { fork } from "@eco-incorp/sauce/testing";
```

### Signature

```ts theme={null}
function fork(chain: ChainRef, options: ForkOptions): Promise<TestChain>;
function fork(chain: SolanaChainRef, options: SolanaForkOptions): Promise<SolanaTestChain>;

interface ForkOptions {
  readonly block: number | bigint;
  readonly state?: string;
  readonly rpcUrl?: string;
  readonly mode?: "auto" | ForkMode; // ForkMode: "replay" | "record" | "live"
  readonly blockInterval?: number;
  readonly zeroFee?: boolean;
  readonly anvil?: string;
}

type SolanaChainRef = "solana" | "sol" | "mainnet-beta" | "solana-mainnet" | 1399811149;
```

### Parameters

| Option | Type | Default | Meaning |
| - | - | - | - |
| `block` | `number \| bigint` | required on EVM | The block the copy starts at |
| `state` | `string` | none | The state file (`.json.gz`). Replayed when it exists, written by `stop()` when it does not |
| `rpcUrl` | `string` | none | The chain's endpoint, read only to record or to fork live. It must serve state at `block` |
| `mode` | `"auto" \| "replay" \| "record" \| "live"` | `"auto"` | How the fork starts; see below |
| `blockInterval` | `number` | 12 on Ethereum, 2 elsewhere | Seconds between blocks after the pinned one |
| `zeroFee` | `boolean` | `false` | `true` clears the Kitchen's fee; by default the chain's fee is kept |
| `anvil` | `string` | `anvil` on `PATH` | The `anvil` binary |

| Mode | Starts when | Needs | What happens |
| - | - | - | - |
| `"replay"` | `state` exists | `state` | Boots from the file and reads no endpoint |
| `"record"` | `state` is named and does not exist | `rpcUrl` | Reads through `rpcUrl`; `stop()` writes every account read to `state` |
| `"live"` | no `state` is named | `rpcUrl` | A plain fork; nothing is recorded |

`"auto"` picks the first row that applies. Passing a mode forces it: `"record"` records the file again, and `"replay"` refuses to start without it:

```text theme={null}
replay needs a recorded state file: test/state/missing.json.gz
```

### Returns

A `TestChain`, or a `SolanaTestChain` when `chain` is Solana.

## local

Starts an empty chain: the release deployed at its addresses, ten test accounts, account 0's Pot, no Kitchen fee, and no protocols or tokens. It needs no network.

### Import

```ts theme={null}
import { local } from "@eco-incorp/sauce/testing";
```

### Signature

```ts theme={null}
function local(options?: LocalOptions): Promise<TestChain>;
function local(options: { readonly chain: SolanaChainRef }): Promise<SolanaTestChain>;

interface LocalOptions {
  readonly chain?: ChainRef;
  readonly anvil?: string;
}
```

### Parameters

| Option | Type | Default | Meaning |
| - | - | - | - |
| `chain` | `ChainRef` | `"base"` | The chain the node runs as: its chain id, and the names programs compile with |
| `anvil` | `string` | `anvil` on `PATH` | The `anvil` binary. EVM only |

`local({ chain: "solana" })` starts an empty Solana in LiteSVM with the engine, the Kitchen, the runtime's own programs and wrapped SOL.

### Returns

A `TestChain`, or a `SolanaTestChain` for Solana.

## TestChain

What `fork` and `local` return on an EVM chain.

| Call | What it does |
| - | - |
| `account(index?: number): TestAccount` | Test account `index` (0 to 9): `index`, `address`, `privateKey`, and `pot`, its Pot under the default salt |
| `fund(holder: Address, amounts: Readonly<Record<string, string \| bigint>>): Promise<void>` | Adds tokens, and the native coin under its symbol: a string is whole tokens, a `bigint` base units |
| `balanceOf(holder: Address, token: string): Promise<bigint>` | A balance in base units. `token` is a registry symbol, an address or the native symbol |
| `run(program: ProgramInput, options?: CookOptions): Promise<CookResult>` | Sends the program as a cook from a test account's Pot and waits for it to be mined |
| `simulate(program: ProgramInput, options?: CookOptions): Promise<CookResult>` | Runs the same cook without sending it. It deploys the Pot if it is not deployed yet |
| `compile(program: ProgramInput): CompiledProgram` | The program compiled for the chain: `bytecode`, `params`, `returnType`, and `payload(args)` for the cook's `program` |
| `impersonate<T>(address: Address, body: (send) => Promise<T>): Promise<T>` | Sends transactions as any address while `body` runs, such as a holder of a token `fund` cannot write |
| `deployPot(account?: number \| TestAccount): Promise<Address>` | Deploys a test account's Pot now instead of on its first cook |
| `executionFee(): Promise<bigint>` | The Kitchen's fee, which every cook pays as value |
| `snapshot(): Promise<void>`, `revert(): Promise<void>` | Saves the chain's state and returns to it. Snapshots nest |
| `increaseTime(seconds: number \| bigint): Promise<void>` | Moves the clock forward and mines a block there |
| `mine(blocks?: number): Promise<void>`, `now(): Promise<bigint>` | Mines empty blocks; reads the latest block's timestamp |
| `stop(): Promise<void>` | Stops the node. A recording chain writes its state file here, so await it |

| Property | What it is |
| - | - |
| `chain` | The chain record the node runs as |
| `mode` | `"replay"`, `"record"`, `"live"`, or `"local"` for an empty chain |
| `rpcUrl` | The local node's URL |
| `client`, `test` | viem's `PublicClient` and `TestClient` for the node |
| `engine`, `kitchen`, `router` | The release's contracts on the node |
| `nativeSymbol` | The chain's native coin symbol, as `fund` and `balanceOf` take it |

`fund` writes a token balance into the token's own storage, in the slot its `balanceOf` reads; the total supply does not change. It refuses a token whose balance is computed rather than stored, such as a rebasing token, and a Base B20 token, a node precompile `anvil` cannot run. Send such a token from a holder with `impersonate`.

### CookOptions

```ts theme={null}
interface CookOptions {
  readonly from?: number | TestAccount;
  readonly args?: readonly EntryValue[];
  readonly value?: bigint;
}
```

| Option | Default | Meaning |
| - | - | - |
| `from` | `0` | The test account whose Pot cooks: an index or a `TestAccount` |
| `args` | none | `main`'s arguments in order: integers as `bigint` or `number`, addresses and bytes as hex |
| `value` | `0n` | Native coin sent beyond the Kitchen's fee, in wei. The kit adds the fee |

A program is a `ProgramInput`: a path relative to the working directory, a `file:` URL, or `{ source, path? }`. It compiles with the chain's names, and its relative imports resolve beside the file.

### CookResult

```ts theme={null}
interface CookResult {
  readonly hash?: Hex;
  readonly gasUsed: bigint;
  readonly returnData: Hex;
  readonly returned: Value | undefined;
  readonly transfers: readonly Movement[];
  readonly effects: Effects;
  delta(holder: Address, token: string): bigint;
}
```

| Field | What it is |
| - | - |
| `returned` | What `main` returned, read as it declares it: integers as `bigint`, addresses and bytes as hex. `undefined` when `main` declares nothing |
| `returnData` | The raw bytes `main` returned |
| `transfers` | Every token and native movement the cook's logs record |
| `effects` | The cook's logs, decoded: transfers, approvals, balance changes and the rest |
| `delta(holder, token)` | How much `holder`'s balance of `token` changed |
| `gasUsed` | The gas the cook consumed |
| `hash` | The cook's transaction hash, after `run` only |

`run` reports the transaction it mined: `returned` and a revert's reason come from the node's trace of that transaction. It sends with the pending block's gas limit as its budget, with no gas estimate and no simulation first, so a cook that is valid only in the block it is mined in is still sent. `gasUsed` is the gas the cook consumed, not that budget; unused gas is not charged. A reverted cook takes a block and a nonce like any transaction. Call `simulate` for a result without sending.

## CookReverted

The error a cook that reverts throws, on EVM chains and on Solana.

```ts theme={null}
class CookReverted extends Error {
  readonly reason: string | undefined;
  readonly data: Hex | undefined;
  readonly logs?: readonly string[];
}
```

| Field | What it is |
| - | - |
| `reason` | The program's `require` or `throw` message, a called contract's `Error(string)` or `Panic`, or a custom error's name |
| `data` | The raw revert data |
| `logs` | On Solana, the transaction's program logs |

## Recordings

A replay boots from the state file at the pinned block's number and timestamp. The file holds every account and storage slot the recording run read, at their values in that block, plus the block's gas limit, the hardfork, the next block's base fee and the test accounts' nonces, so a replayed cook uses the same gas and is the same transaction. Each block after the pinned one is `blockInterval` seconds later, so no result depends on the wall clock.

A replay knows only what the recording read: an account or slot read for the first time reads as zero. After a test starts reading something new, such as another token or pool, or after you move the block, record again: delete the file or pass `mode: "record"`. The state file never contains the endpoint's URL; the kit refuses to write one that does.

## Solana

On Solana, `fork` and `local` return a `SolanaTestChain`. Its calls match `TestChain`'s, with these differences:

* `fork("solana", { state, rpcUrl, mode, zeroFee })` takes no `block`: a Solana node serves accounts only as they are now, so a recording is of the moment you record. The file keeps that slot, its time, and the cluster's rent and epochs, and holds each account the test's transactions named.
* Test accounts are keypairs: `account(i)` has `address`, `secretKey` and `pot`, and each starts with 1,000 SOL. The keys are the same on every run, so a recording made with them replays with them.
* `run` and `simulate` take `accounts`, `main`'s `Account` parameters by name, in the words `sauce run --account` takes (`pot`, `pot:USDC`, `@to:USDC`, `mint:USDC`, `program:USDC`, an address). The accounts the SDK adds for token methods are derived.
* The result is a `SolanaCookResult`: `returned`, `returnData`, `changes`, `delta(holder, token)`, `logs`, `computeUnits`, `signature` after `run`, and `staged` and `lookupTable` for a program too large for one transaction.
* `warp(slots)` and `slot()` move and read the slot; `getAccount(key)` and `setAccount(key, account)` read and write any account.

```ts solana-pay.test.ts theme={null}
import assert from "node:assert/strict";
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
import { CookReverted, fork, type SolanaTestChain } from "@eco-incorp/sauce/testing";

const PAY = new URL("./pay.sauce.ts", import.meta.url);

describe("pay.sauce.ts on Solana", () => {
  let solana: SolanaTestChain;
  before(async () => {
    solana = await fork("solana", {
      state: "test/state/pay-solana.json.gz",
      rpcUrl: process.env.SOLANA_RPC_URL, // read only while the state file does not exist
    });
  });
  after(() => solana.stop());
  beforeEach(() => solana.snapshot());
  afterEach(() => solana.revert());

  it("pays USDC from the Pot", async () => {
    const pot = solana.account(0).pot;
    const recipient = solana.account(1).address;
    await solana.fund(pot, { USDC: "1" });

    const result = await solana.run(PAY, { args: [recipient, 400n] });

    assert.equal(result.delta(recipient, "USDC"), 400n);
    assert.equal(result.delta(pot, "USDC"), -400n);
  });

  it("refuses more than the Pot holds", async () => {
    await assert.rejects(
      solana.simulate(PAY, { args: [solana.account(1).address, 400n] }),
      (error) => error instanceof CookReverted && error.reason === "not enough USDC",
    );
  });
});
```

The test reads the Solana endpoint from an environment variable your command sets, so the URL stays out of the repository; the kit itself reads none.

## Next steps

* [Compiling](https://docs.eco.com/sauce-sdk/typescript/compiling.md): what `compile` and the kit's compile do.
* [Cook transactions](https://docs.eco.com/sauce-sdk/typescript/cook-transactions.md): build and simulate the same cook against a live chain.
* [sauce simulate](https://docs.eco.com/sauce-sdk/cli/simulate.md): simulate a cook from a terminal.
* [Pot](https://docs.eco.com/sauce/pot.md) and [cook](https://docs.eco.com/sauce/cook.md): what a test account's Pot and a cook are.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.