@tenderly/cre-preflight
v0.1.1
Published
Preflight a transaction on a Tenderly Virtual Environment from inside a Chainlink CRE workflow, and get a consensus-verified verdict back.
Readme
@tenderly/cre-preflight
Simulate an EVM transaction on a Tenderly Virtual Environment from inside a Chainlink CRE workflow, and get a consensus-verified verdict back.
Each node forks the chain at an agreed block, applies any funding and state overrides you asked for, sends the transaction, and reads the receipt. The transaction is mined on the fork, so it behaves exactly as it would on the real chain, and nothing reaches the real chain. Use it to find out what a transaction would do before your workflow commits to it.
const verdict = tenderly.sendTransaction(runtime, { from, to, data })
if (verdict.outcome !== 'success') {
runtime.log(`skipping write: ${verdict.outcome} ${verdict.reason}`)
return 'skipped'
}Install
bun add @tenderly/cre-preflight@chainlink/cre-sdk (1.22 or newer, below 2.0) and zod 3 are peer
dependencies. Your workflow already has both: @chainlink/cre-sdk depends on
zod 3 itself. The schemas this package exports (tenderlyConfigSchema,
transactionSchema, ...) are zod 3 schemas, so if your own config schema is
written in zod 4, validate this package's slice of it separately rather than
nesting one inside the other.
You also need Bun 1.4 or newer. Older versions silently produce a WASM binary
that traps at handler registration with wasm trap: unreachable and no useful
diagnostic. The CRE installer only checks for Bun >= 1.0.0, so this is easy to
hit.
Usage
import { TenderlyPreflight } from '@tenderly/cre-preflight'
const tenderly = new TenderlyPreflight(runtime.config.tenderly)
const verdict = tenderly.sendTransaction(runtime, {
from: sender,
to: contract,
data: calldata,
})Call it from a DON-mode handler. The transaction is an argument rather than config, because it is usually built from whatever the execution just worked out.
Configuration
TenderlyPreflight takes a plain object, so build it however suits you. Reading it
from config.json means you can change it without touching workflow code, which
is why the examples here do that, but a literal in code works just as well and
so does a mix:
new TenderlyPreflight({
...runtime.config.tenderly,
fork: { networkId: '1', at: 'finalized' },
})Every object is strict: a misspelled key such as deleteEnviroment is an error,
not a setting that is silently ignored while the default stays in force.
The one real constraint is the credential. accessKeySecretId is a secret
name, which the library resolves from the CRE Vault at call time. There is
no field that takes an access key, so the value never appears in config or in
your workflow source.
Use a Tenderly access key scoped to the one project this workflow forks in, and nothing more. The key is read as an ordinary CRE secret, so every node in the DON holds it in memory while the workflow runs.
{
"tenderly": {
"accountSlug": "my-account",
"projectSlug": "my-project",
"accessKeySecretId": "tenderlyaccesskey",
"fork": {
"networkId": "1",
"at": "latest" // or "finalized", or a block number
},
// all optional, shown with their defaults
"displayName": "cre-preflight",
"region": "eu", // "eu" or "us-east"; omit to let Tenderly choose
"explainReverts": true,
"includeGasUsed": true,
"deleteEnvironment": true,
"maxResponseBytes": 256000
}
}| Option | Default | What it does |
|---|---|---|
| accountSlug, projectSlug | required | Where environments are created. |
| accessKeySecretId | required | Name of the CRE secret holding your Tenderly access key. |
| fork.networkId | required | Chain id as a decimal string. The chain is derived from this alone. |
| fork.at | required | "latest", "finalized", or a decimal block number. |
| displayName | cre-preflight | Name the environments appear under in Tenderly. |
| region | unset | "eu" or "us-east". Shows up in the Admin RPC hostname. |
| explainReverts | true | Recover the revert reason. Costs one extra HTTP action, and only on the revert path. |
| includeGasUsed | true | Report gas. Free, it is already in the receipt. |
| deleteEnvironment | true | Delete each node's fork when the transaction finishes. |
| maxResponseBytes | 256000 | Treat a JSON-RPC response at or above this as unusable. |
Choosing the fork block
A node must never resolve latest for itself, because each one would see a
different chain head and the forks would diverge. fork.at handles that: a tag
is resolved once per execution, through a consensus-verified chain read, and
every node is handed the same block number.
| fork.at | When to use it |
|---|---|
| "latest" | Normal choice. Closest to current state, and still safe. |
| "finalized" | When the answer must survive a reorg. On Sepolia it measured about 84 blocks behind the head, so it judges against materially older state. |
| "6000000" | A pinned block. No chain read at all. |
Not every chain CRE knows can be forked. If Tenderly does not support the
network, the first call fails with misconfigured and reason reads
Unsupported network id. The
supported networks list
is the place to check before you deploy.
If your workflow already knows the height it cares about, pass it directly and
skip the read. It must be a native bigint; a log's blockNumber is a protobuf
BigInt, so convert it first:
import { protoBigIntToBigint } from '@chainlink/cre-sdk'
const forkBlockNumber = log.blockNumber && protoBigIntToBigint(log.blockNumber)
tenderly.sendTransaction(runtime, tx, { forkBlockNumber })When forkBlockNumber is undefined, fork.at is used as usual.
Funding and state overrides
Both are per-call options, because what needs funding depends on the transaction you are simulating.
tenderly.sendTransaction(runtime, tx, {
fund: [
{ addresses: [alice, bob], balance: parseEther('10') },
{ token: DAI, holders: [alice], balance: parseUnits('100', 18) },
],
stateOverrides: {
[DAI]: { stateDiff: { [slot]: 100n * 10n ** 18n } },
[oracle]: { code: '0x60806040...' },
},
})fund takes a list. An entry with addresses sets a native balance; an
entry with token and holders sets an ERC20 balance, and funds every holder
in that entry at once.
stateOverrides sets balance, nonce, code or individual storage slots
directly. Reach for it when you know the layout you want to change, or when you
need something fund does not cover.
Balances and storage words take a bigint or a hex string, and a bigint slot
or value is left-padded to 32 bytes for you. Where both name the same account,
stateOverrides wins.
parseEther and parseUnits above are viem's, not this
library's. viem is already in your tree as a dependency of @chainlink/cre-sdk.
Plain bigint literals work just as well.
Costs differ: native balances and stateOverrides are free, because they travel
with the transaction itself. Each ERC20 fund entry costs one HTTP action, in
exchange for not making you work out the token's storage slot, which breaks on
proxies, packed slots and Vyper layouts.
One thing to keep in mind: the sender's real balance is part of what a guard is asking about. Topping the sender up turns "would this succeed if I sent it now" into an easier question, and hides an insufficient-funds failure behind a green light. Fund deliberately, for scenarios where the balance is not what you are testing.
The verdict
interface TransactionVerdict {
outcome:
| 'success'
| 'reverted'
| 'rejected'
| 'misconfigured'
| 'unavailable'
| 'oversized'
| 'indeterminate'
reverted: boolean
reason: string // decoded Error(string) / Panic(uint256) / custom selector
gasUsed: bigint
}| outcome | Meaning |
|---|---|
| success | The transaction would succeed. |
| reverted | It executed and reverted. reason says why when explainReverts is on. |
| rejected | The node refused it before executing, usually insufficient funds for gas. An answer, not a fault: send it and it fails. reason carries the node's own words. |
| misconfigured | The request can never succeed as written: an unsupported network, a project that does not exist, a key without permission, a secret that cannot be read, a fund entry Tenderly cannot apply. reason says which. Retrying will not help. |
| unavailable | Tenderly or the chain could not be reached, answered with an error worth retrying (5xx, rate limiting), or gave an answer too incomplete to judge. Says nothing about your transaction. |
| oversized | A response was too large to read. The transaction is too complex to report on this way; nothing is wrong with Tenderly. |
| indeterminate | The nodes did not agree, so no verdict can be trusted. |
Check outcome, not just reverted. The last four are not failures of your
transaction and should usually not be treated as one.
misconfigured is the one worth alerting on. It means the workflow will keep
failing every run until someone changes the config, the secret, or the options
passed with the call, unlike unavailable, which usually clears by itself.
sendTransaction throws only for invalid arguments: a malformed transaction,
fund entry, state override, or forkBlockNumber. Everything that can go wrong
at run time comes back as a verdict, so switching on outcome covers every
case.
reason comes from the node and is passed through as text, with the Admin RPC
URL and the access key scrubbed out. Treat it as something to log, not to parse.
A disabled field is zeroed ('', 0n) rather than absent, because the verdict
shape has to be stable across nodes for consensus.
Keeping environments for inspection
deleteEnvironment: false keeps each node's fork instead of deleting it. Open
it in the Tenderly dashboard afterwards and you will find the funded balances and
the mined transaction in place. Each node's environment id is logged either way,
visible in the CRE dashboard's Logs tab.
Every node builds its own environment, so one execution of an eight-node workflow leaves eight behind. On a two-minute cron that is several thousand a day, and you will be cleaning them up by hand. Turn it on to debug something, then turn it back off.
What it costs to run
One environment is created per DON node, per execution, and each node spends its own HTTP budget. DON size is not something you set, and it has varied between runs of the same workflow, so treat the node count as a multiplier to measure rather than assume.
Per node, per execution:
| Step | HTTP actions |
|---|---|
| create the environment | 1 |
| submit the transaction | 1 |
| read the receipt | 1 |
| delete the environment | 1 if deleteEnvironment |
| recover the revert reason | 1 if explainReverts, revert path only |
| each ERC20 fund entry | 1 |
| native funding, stateOverrides | 0 |
new TenderlyPreflight(config).httpActionCost
// 5 by default, 4 without explainReverts, 3 without deleteEnvironment tooThat is reported, not enforced. HTTP quotas may differ between CRE tenants, so
the library does not reject a configuration against a ceiling it cannot know.
Compare it against your own PerWorkflow.HTTPAction.CallLimit if you are
budgeting a workflow.
Resolving a fork.at tag costs one EVM read against ChainRead.CallLimit, which
is a separate budget from HTTP actions.
Response size
Responses are measured, and anything at or above maxResponseBytes is reported
as oversized rather than allowed to look like an outage. Receipt size grows
with log count, which you do not control, so this can happen on a legitimate
transaction with a lot of events.
Lower maxResponseBytes if you want to fail earlier than the platform would. It
applies to JSON-RPC responses only. Environment creation is always read up to the
platform limit, because refusing that response would leave an environment behind
that nothing knows to delete.
Gotchas
z.string().url() does not work inside a CRE workflow. zod validates a URL
by calling new URL(value), and URL is undefined in QuickJS, so every value
including a valid one is reported as invalid. If you write your own config
schema, use a regex instead.
gasUsed is a median across nodes; the decision fields must agree.
outcome, reverted and reason need a quorum of nodes reporting identical
values. Blocks mined on a Virtual Environment carry wall-clock timestamps, so
nodes execute at slightly different block.timestamp values. A contract whose
gas depends on time would otherwise fail consensus over a harmless difference. A
strongly time-dependent contract can still legitimately disagree, which comes
back as indeterminate. So can a revert message that embeds the timestamp or
another per-node value: set explainReverts: false for such a contract and
decide on outcome alone.
Nothing environment-specific comes back in the verdict. Each node has its own fork, so its id and URLs cannot survive consensus. They are logged instead.
Development
bun install
bun run typecheck
bun test
bun run build
bun run compile:example # packs the library and compiles the example workflow to WASMThe SDK's own build-time checks (cre-compile's runtime-compatibility and
determinism validators) only scan a workflow's local files, never its
dependencies, so they do not cover this package. compile:example runs the real
compile pipeline against the packed tarball instead, the way a user's workflow
would consume it.
Releasing
Releases go through Changesets. Add one with your change:
bun run changesetWhen it lands on main, the release workflow opens a "Release" pull request
that bumps the version and the changelog. Merging that pull request publishes
to npm with provenance, through npm Trusted Publishing: the workflow
authenticates with its GitHub OIDC identity, so no npm token is stored.
License
MIT
