npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 too

That 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 WASM

The 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 changeset

When 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