Skip to main content

Testing Against a Devnode

Testing against Aleo's testnet is slow and shared: blocks take real time to produce, funded accounts are scarce, and state left behind by other tests is outside a suite's control. A devnode is a local Aleo node — similar to Foundry's Anvil for Ethereum — that a test suite starts, drives, and tears down by itself. It seeds a funded genesis account, can mine blocks on demand instead of waiting for real block production, and its ledger can be snapshotted and restored between test cases.

@provablehq/veil-aleo-devnode drives the aleo-devnode binary as a subprocess; it must be installed and resolvable on PATH. Pair it with @provablehq/veil-leo to compile and deploy the program under test onto the node it starts.

Quick start​

For a script or a single test that needs nothing more than a working client against a running devnode, createDevnodeClient returns a wired publicClient/walletClient/account triple in one call, no loadNetwork required:

import { createDevnodeClient } from '@provablehq/veil-aleo-sdk'

const { publicClient, walletClient, account } = createDevnodeClient()
// account is the devnode's seeded, pre-funded key at 127.0.0.1:3030

const txId = await walletClient.writeContract({
program: 'credits.aleo',
function: 'transfer_public',
inputs: ['aleo1recipient...', '1000000u64'],
})

This assumes a devnode is already running at the default socket. The rest of this guide covers starting and driving that node.

Starting the node​

startDevnode spawns the binary and resolves once its REST API answers, so the returned instance is ready to receive transactions:

import { startDevnode, DEVNODE_ADDR } from '@provablehq/veil-aleo-devnode'

const devnode = await startDevnode({
socketAddr: DEVNODE_ADDR, // '127.0.0.1:3030'
storagePath: '', // '' -> default ./devnode dir; omit for in-memory
})

// ...broadcast transactions against http://127.0.0.1:3030...

await devnode.stop()

By default the node mines a block automatically after every broadcast. Pass manualBlockCreation: true for deterministic block timing instead, and mine on demand with advanceDevnode:

const devnode = await startDevnode({ manualBlockCreation: true })

// ...broadcast a transaction...

await advanceDevnode({ numBlocks: 1 }) // mine a block so it can be accepted

Driving the node from a test client​

devnodeActions folds startDevnode, advanceDevnode, and restoreDevnode onto a test client via .extend, so one client both drives the node process and, through the core test actions, reads and mutates its state:

import { createTestClient, http } from '@provablehq/veil-core'
import { devnodeActions } from '@provablehq/veil-aleo-devnode'

const client = createTestClient({
transport: http('http://127.0.0.1:3030', { network: 'testnet' }),
}).extend(devnodeActions)

const devnode = await client.startDevnode({ storagePath: '' })
await client.advanceDevnode({ numBlocks: 1 })

advanceBlock is the core test action of the same shape, for a client that already has a devnode running and only needs to move the chain forward. The devnode MUST be running with manualBlockCreation:

await client.advanceBlock({ count: 5 }) // mines 5 blocks, one request per block

Building and deploying the program under test​

leoActions attaches a .leo property to any veil client, running the leo CLI against a project directory. Leo operations need no transport, so the extension composes onto the same test client:

import { leoActions } from '@provablehq/veil-leo'
import { DEVNODE_PRIVATE_KEY } from '@provablehq/veil-aleo-devnode'

const client = createTestClient({
transport: http('http://127.0.0.1:3030', { network: 'testnet' }),
})
.extend(devnodeActions)
.extend(leoActions({
cwd: './programs/loyalty_token',
network: 'testnet',
endpoint: 'http://127.0.0.1:3030',
privateKey: DEVNODE_PRIVATE_KEY,
}))

await client.startDevnode({ storagePath: '' })

// Compile the package at cwd.
await client.leo.build()

// Deploy it, broadcasting the transaction and skipping the confirmation prompt.
await client.leo.deploy({ broadcast: true, yes: true })

leoActions's config sets defaults — cwd, network, privateKey, and the rest — for every .leo call. Deploying needs a signing key; the devnode's seeded DEVNODE_PRIVATE_KEY is funded from genesis and ready to pay the deployment fee immediately.

Snapshotting state​

Taking a snapshot is a live REST call against the running node rather than an aleo-devnode subcommand, so it lives on the core test client as snapshot (and listSnapshots to enumerate them), with restoreDevnode here to reload one. This lets a test suite capture a point after deployment and rewind to it between cases instead of redeploying from scratch:

const { name } = await client.snapshot({ name: 'after-deploy' })

// ...run a test that mutates state...

await client.restoreDevnode({ snapshot: name, restart: true })

Restoring requires the node to have been started with storagePath — an in-memory node has nothing on disk to snapshot.

Full example​

import { createTestClient, http } from '@provablehq/veil-core'
import { devnodeActions, DEVNODE_PRIVATE_KEY } from '@provablehq/veil-aleo-devnode'
import { leoActions } from '@provablehq/veil-leo'

const client = createTestClient({
transport: http('http://127.0.0.1:3030', { network: 'testnet' }),
})
.extend(devnodeActions)
.extend(leoActions({
cwd: './programs/loyalty_token',
network: 'testnet',
endpoint: 'http://127.0.0.1:3030',
privateKey: DEVNODE_PRIVATE_KEY,
}))

const devnode = await client.startDevnode({
storagePath: '',
manualBlockCreation: true,
})

await client.leo.build()
await client.leo.deploy({ broadcast: true, yes: true })
await client.advanceBlock() // confirm the deployment

const { name } = await client.snapshot({ name: 'after-deploy' })

// ...exercise the deployed program, advancing blocks as each call needs
// confirmation...

await client.restoreDevnode({ snapshot: name, restart: true })
await devnode.stop()