A collection of flexible viem extensions with a focus on intelligent caching.
Provides composable transport wrappers for optimized eth_getLogs and deployless eth_call
handling with caching, rate limiting, automatic request splitting, and oversized-log filtering.
pnpm add @morpho-org/viem-dlcAlso available on the GitHub Package Registry.
Four interactive tutorials — eth_getLogs, eth_call, transport composition, and observability —
live in playground/. Each is prose interleaved with runnable, editable
steps that call this package against a live endpoint, so every figure they quote is one you can
reproduce.
The library has two independent halves, and they answer different questions. Neither is a general answer to "how do I read chain data"; each has a scale past which something else is the right tool.
| What you need | Use | Holds up to | What replaces it past that |
|---|---|---|---|
| History and discovery — events over a block range | the cache transport |
roughly 10 MB of zstd-compressed logs per query in serverless memory (about eight months of Morpho Vault V2 history), or roughly 100 MB on a developer machine (every Morpho Blue borrow event ever emitted) | an indexer surfacing raw events |
| The freshest current state — the same question about many subjects, right now | a batched lens read: readLens, or deployless with policy |
tens of thousands of elements before a provider's gas budget binds | an indexer accumulating derived state |
The rule of thumb: index for history and discovery, read a lens for the freshest current state. Most systems want both — discover the subject list from logs, then read its live state through a lens.
A lens fits when you ask the same question about many subjects and each answer stands alone. That
independence is a hard requirement rather than a preference — see the
semantic requirement on policy. It fits best when you want the current block
rather than history, and when the answer is derived from several storage reads rather than sitting
in one field.
Against a multicall, almost always. Whatever the provider's gas cap and rate limits, a lens
sends less calldata, decodes faster, and splits under pressure instead of failing. A low gas cap
means more calls to answer the same list; it does not mean multicall would have done better,
because a multicall aggregates into one call under one gas budget. One item that burns unbounded
gas fails the whole batch, and every retry fails the same way — so anyone who can get an address
into your input list can trigger that deliberately. This library bisects the offender out instead,
which costs a few extra round trips rather than the query.
Against an indexer that accumulates derived state, usually — until the budget binds. Such an indexer has to be deployed, backfilled, and kept correct across reorgs, and what it serves is always at least one block stale. A lens reads live chain state with nothing to stand up, and freshness is guaranteed by construction. The provider's gas cap and rate limit are what eventually bite; past that point there is a real trade, and the usual split is derived state from an indexer plus a lens for the fast-moving inputs.
Caching does an indexer's job up to a point. For history and discovery the two are interchangeable until the query outgrows its host, at the sizes in the table above. Past that, indexing is required rather than preferred.
It is also the fastest way to start, because there is nothing to deploy and nothing to backfill. That is what makes it the right choice for experimentation and for standing something up quickly, and it is worth knowing which of those two properties you are relying on: a cache that has quietly become the thing you index with will hit its ceiling as a surprise.
This library can emit structured events through a logger you provide. The expected
shape is a structural subset of loglayer
— a LogLayer instance satisfies it directly — but loglayer is not a declared
peer dependency, so it isn't installed transitively and isn't required to typecheck.
Pass any value matching the exported Logger interface (child, withContext,
withMetadata, withError, info, warn, error, metadataOnly).
pnpm add loglayer # only if you want to use it as the loggerIf you don't call withLogging, the library emits nothing and the dep is irrelevant.
import { withLogging } from '@morpho-org/viem-dlc'
await withLogging(() => client.request({ method: 'eth_getLogs', params: [filter] }), {
logger, // anything satisfying the `Logger` interface, such as a LogLayer instance
service: 'indexer', // extra opts become context fields on every event
})Each outermost client.request made inside a withLogging scope emits a single
"concluded" wide event. Transports contribute flat, queryable fields under their
key — viem-dlc-failover.succeeded_index, viem-dlc-logs-divider.logs_fetched —
and layers crossed many times per call, such as the divider once per chunk,
accumulate totals there, such as viem-dlc-logs-sieve.logs_dropped. If a call crosses
several instances of the same transport — say, one cache per failover branch — later
instances are suffixed .1, .2, ... in first-touch order, which is stable for a
given composition. Every layer also stamps a per-instance crossings count, so the
event records which transports the call traversed and how many times each. Call-level
fields are call_id, chain_id (when the client has a chain), duration_ms, and status
("ok" or "error"). Failed calls
emit at error level with the error attached via withError, so hosts that forward
withError entries to an error reporter such as Sentry capture them automatically.
Splits a deployless eth_call into as many calls as it takes. It intercepts only calls carrying
the policy(...) sentinel in stateOverride: it re-packs the marked input array into one or more
deployless-factory calls under a wire byte budget — the chain's initcode limit, and the transport's
batchSize where stated — and aggregates the pages that come back. It forwards everything else
unchanged.
No gas figure is load-bearing: the envelope calls the lens's per-item function once per element
in its own frame and reports how far it got, so a chunk adapts to whatever gas the node grants —
see Paginated lenses. An optional gasLimit lets the opening wave
anticipate the grant, and rides as each chunk's gas on chains that need it (see
Chains). A chunk's elements ride inside the envelope's initcode by default, so the
chain's initcode limit bounds it; with batch.envelope: 'override' the envelope is placed by state
override instead and only the frame's gas and the provider's request size bound a chunk. Most
callers reach it through readLens rather than building the call by hand.
import { createPublicClient, defineChain, encodeFunctionData, http, parseAbiItem } from 'viem'
import { mainnet } from 'viem/chains'
import { call } from 'viem/actions'
import { deployless } from '@morpho-org/viem-dlc/transports'
import { arrayifiedAbi, policy } from '@morpho-org/viem-dlc/actions'
import { chainConfig, ethereumFacts } from '@morpho-org/viem-dlc/chains'
// The lens implements `positionOf((bytes32,address)) view returns ((uint256,uint128,uint128))`;
// the array-shaped fragment the wire carries is derived from it.
const positionsAbi = arrayifiedAbi(
parseAbiItem('function positionOf((bytes32 id, address user) input) view returns ((uint256,uint128,uint128))')
)
// The chunk rides as initcode, which the chain's limit bounds — see [Chains](#chains).
const chain = defineChain({ ...mainnet, ...chainConfig }).extend({ viemDlc: ethereumFacts })
const client = createPublicClient({ chain, transport: deployless(http(rpcUrl)) })
const result = await call(client, {
factory,
factoryData,
to,
data: encodeFunctionData({ abi: [positionsAbi], functionName: 'positionOf', args: [inputs] }),
stateOverride: [policy({ abi: positionsAbi, batch: { compress: true } })],
})If policy.cache is present, deployless(...) ignores it and stays in split-only mode.
Use cache(...) when you want the same marked calls to populate and read from a backing store.
Both transports take two optional figures about the provider they were pointed at.
gasLimit is its eth_call gas cap: deployless(http(rpcUrl), { gasLimit: 50_000_000 }), or
gasLimit beside binSize in the cache config. Together with the policy's batch.gas it sizes
the opening wave. Every later chunk is sized from what the pages report, so a value that is too low
costs a round trip, never a result.
Some chains run an eth_call that leaves gas unspecified in a fixed default below the cap — Monad
grants 8.1M, and promotes only on out-of-gas, which a paging envelope never is. On those chains the
transports also send gasLimit as every chunk's gas, and a value above the provider's cap makes
the node reject the request. State the cap the provider documents. Whether a chain behaves this way
is a fact the chain carries; Chains records it.
batchSize is the largest request the provider accepts, in bytes of a chunk's eth_call data.
State what the provider documents, often a few megabytes.
Behind failover, each branch states its own.
With observability enabled, batching reports elements_requested / elements_fetched,
nominal_batches and batch_bytes (sizes of the initial packing against the wire budget;
halved and continued chunks are not resampled), and splits_* for chunks halved after an
error: splits_size (413 / initcode-size errors) and splits_timeout. The envelope's prologue
grows with the chunk only by the copy of its bytes, which the packer prices, so a frame that dies
without reporting is a constructor too heavy for the node's cap, surfaced as an error rather than
halved. Pagination is normal rather than a failure and gets its own
fields: pages_continued (responses that stopped early; the elements they did not reach are pooled
and re-packed together), flushes (the requests those pooled elements were re-packed into, split
into flushes_full, flushes_drain and flushes_eager by what released them — see
batch.continuations), continuation_depth_max (the longest chain of pages behind pages),
page_adjudicated (elements per page, as a stat — a lens yielding ~1 per page is
pathological), pages_all_skipped (pages whose every element reverted — a per-item selector the
lens does not implement is one cause), attempts_unresolved (elements a frame's gas could not
resolve, whether the per-item frame died or the envelope refused to start it), pages_escalated
(singleton retries those cost), and, matching the response's
skipped array: elements_missing in total, of which elements_declined_oversize could not fit
a chunk alone under the byte budget and elements_unresolved were gas-terminal even alone —
the subset another provider with a higher cap might still serve.
Every page also reports what its attempts cost, and the request pools it: frame_gas (the gas a
frame had for attempts, on the smallest frame seen), fixed_gas (what a frame spent before its
first attempt — prologue, lens deploy and reserve — less the copy of the chunk's own bytes, so the
figure does not depend on how large the observed pages were), item_gas_avg / item_gas_stddev /
item_gas_max per attempt, and gas_limit_observed, the eth_call gas cap the provider actually
granted, read back from the frame, the prologue and the calldata's intrinsic gas. Every chunk after
the opening wave is packed from these; they are also the numbers the opening wave takes as
configuration: fixed_gas, item_gas_avg and item_gas_stddev go to the policy's
batch.gas, and gas_limit_observed to each transport's gasLimit (stamped as gas_limit when it
applied). Take them over a representative window; a gas_limit above gas_limit_observed is a
cap the provider has since lowered. Costs depend on which items share a frame: grouping related
elements warms storage they share and lowers item_gas_avg, shuffling makes the rate uniform
across chunks; results align to args in either order. A full cache hit or an empty input makes
no upstream call and carries none of these fields.
Delivery has its own fields: chunks_override and chunks_initcode (requests sent in each
delivery), override_fallbacks (ranges re-fetched as initcode, split into
override_fallbacks_unsupported, override_fallbacks_unproven and override_fallbacks_exhausted
by what the failed chunk proved). A provider that never honours overrides shows one unsupported
fallback per request; turn the option off for it.
Caches eth_getLogs and eth_call through one transport. It composes five layers: oversized-log
filtering (logsSieve), log enrichment (logsEnricher), rate limiting (rateLimiter), request
splitting (logsDivider), and caching. It needs a chain on the client, so that it can namespace
cache keys by chain ID.
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { cache, createSimpleInvalidation } from '@morpho-org/viem-dlc/transports/cache'
import { LruStore } from '@morpho-org/viem-dlc/stores'
const transport = cache(http(rpcUrl), [
{
binSize: 10_000,
store: new LruStore({ maxBytes: 100_000_000 }),
invalidationStrategy: createSimpleInvalidation(),
},
{
maxBlockRange: 100_000,
},
{
retryCount: 3,
retryDelay: 1_000,
blockTimestamp: false,
},
{
maxBytes: 8_192,
},
{
maxRequestsPerSecond: 10,
maxBurstRequests: 5,
maxConcurrentRequests: 5,
},
])
const client = createPublicClient({ chain: mainnet, transport })binSize sets the granularity of a cache entry. The transport aligns requests to bin boundaries
so that overlapping windows hit the same entries, and it sets the logsDivider config's alignTo
from binSize for you. Smaller bins invalidate more precisely and cost more entries; larger bins
do the opposite.
The package ships two invalidation strategies:
createSimpleInvalidation(minAgeMs?, maxAgeDays?, numHotBlocks?, avgInvalidationsPerRequest?)— entries near the chain tip are always refetched; older entries are probabilistically invalidated based on age.createExponentialInvalidation(alphaAge?, maxAgeDays?, alphaBlocks?, scaleBlocks?)— exponential model with separate time and block-age factors.
Dispatches a request across several RPC providers, each with its own limits. Each branch is a
fully-built per-provider stack carrying its own maxBlockRange and, optionally, its own gasLimit.
Deployless lenses adapt to each node's grant on their own, and the cap sizes the opening wave.
failover builds each branch once, at composition time, so stateful inner transports — coalescing
mutexes, rate-limiter token buckets — persist across requests instead of being rebuilt per call.
viem's stock fallback rebuilds the active branch on every request, which disables those features.
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { failover } from '@morpho-org/viem-dlc/transports'
import { cache, createSimpleInvalidation } from '@morpho-org/viem-dlc/transports/cache'
import { LruStore } from '@morpho-org/viem-dlc/stores'
const store = new LruStore({ maxBytes: 100_000_000 })
const sharedConfig = { binSize: 10_000, store, invalidationStrategy: createSimpleInvalidation() }
const transport = failover([
cache(http(rpcUrlA), [sharedConfig, { maxBlockRange: 100_000 }]),
cache(http(rpcUrlB), [sharedConfig, { maxBlockRange: 10_000 }]),
])
const client = createPublicClient({ chain: mainnet, transport })Each branch's logsDivider chunks requests at its own maxBlockRange, so neither provider
is sized for the lowest common denominator. The shared Store means partial fetches from
branch A persist in cache and are visible to branch B on fallover, making recovery cheap.
failover only sees errors that escape per-branch halving (logsDivider range-halving and
deployless halving run inside each branch first). By default, contract reverts and
user-rejection errors propagate immediately instead of triggering fallover — pass a custom
shouldThrow to override:
import { defaultShouldThrow, failover } from '@morpho-org/viem-dlc/transports'
failover([branchA, branchB], {
shouldThrow: (err) =>
defaultShouldThrow(err) ||
[401, 402, 403].includes((err as { status?: number })?.status ?? 0),
})Splits a large eth_getLogs request into smaller chunks, retrying failures and aligning
boundaries on request. It composes rateLimiter for rate and concurrency limits, logsEnricher
for enrichment, and logsSieve for oversized-log filtering.
import { createPublicClient, http } from 'viem'
import { logsDivider } from '@morpho-org/viem-dlc/transports'
const transport = logsDivider(http(rpcUrl), [
{
maxBlockRange: 100_000,
alignTo: 10_000,
},
{
retryCount: 3,
retryDelay: 1_000,
blockTimestamp: false,
},
{
maxBytes: 8_192,
},
{
maxRequestsPerSecond: 10,
maxConcurrentRequests: 5,
},
])
const client = createPublicClient({ transport })
const logs = await client.request({
method: 'eth_getLogs',
params: [
filter,
undefined,
{
onLogsResponse: ({ logs, fromBlock, toBlock }) => {
/* progressive updates */
},
},
],
})Enriches eth_getLogs responses with data that standard RPCs omit. Today it populates
blockTimestamp by fetching block headers. It drops logs whose block was reorged away, without
reporting them.
import { createPublicClient, http } from 'viem'
import { logsEnricher } from '@morpho-org/viem-dlc/transports'
const transport = logsEnricher(http(rpcUrl), [{
retryCount: 3,
retryDelay: 1_000,
blockTimestamp: true,
}])
const client = createPublicClient({ transport })Filters eth_getLogs responses by estimated UTF-8 payload size. It drops any RpcLog whose
serialized size exceeds maxBytes, without reporting it. logsDivider(...) and cache(...) already
compose this transport by default; use logsSieve(...) directly when filtering is all you need.
import { createPublicClient, http } from 'viem'
import { logsSieve } from '@morpho-org/viem-dlc/transports'
const transport = logsSieve(http(rpcUrl), [{ maxBytes: 8_192 }])
const client = createPublicClient({ transport })Limits request rate with a token bucket, bounds concurrency, and schedules by priority. With
observability enabled it reports queue_wait_ms (admission wait, summarized over
every crossing in the call), which separates time spent queued behind your own limits
from time spent waiting on the upstream RPC:
import { createPublicClient, http } from 'viem'
import { rateLimiter } from '@morpho-org/viem-dlc/transports'
const transport = rateLimiter(http(rpcUrl), [
{
maxRequestsPerSecond: 10,
maxBurstRequests: 5,
maxConcurrentRequests: 3,
},
])
const client = createPublicClient({ transport })
await client.request({
method: 'eth_getLogs',
params: [
filter,
{
__rateLimiter: true,
priority: 0,
},
],
})Two things this package needs are facts of the chain rather than of the caller, so the chain the
client is built with carries them, on viem's extendSchema:
ethCall.gasWhenUnspecified— the frame a request that leavesgasunspecified runs in: the provider's whole cap (providerCap, geth), or a fixed default below it (fixedDefault; Monad grants 8.1M and promotes only on out-of-gas, which a paging envelope never is). The transports sendgasLimitas each chunk'sgason afixedDefaultchain and nothing elsewhere, sogas_limit_observedreads the true cap wherever a node would reveal it.ethCall.gasAboveCap— what the node does with agasabove the provider's cap:clamped(geth) orrejected(Monad,gas limit too high).maxInitcodeSize— the largest initcode the chain's nodes accept. An initcode-delivered chunk's bytes are the initcode, so this bounds one; EIP-3860's 49 152 nearly everywhere, 262 144 on Monad.
Nothing is assumed. ethereumFacts and monadFacts are the two this package has probed; attach one
of them, or state your own for a chain neither describes, and nothing needs upstreaming here:
import { defineChain } from 'viem'
import { monad } from 'viem/chains'
import { chainConfig, monadFacts } from '@morpho-org/viem-dlc/chains'
const chain = defineChain({ ...monad, ...chainConfig }).extend({ viemDlc: monadFacts })
const client = createPublicClient({ chain, transport: deployless(http(rpcUrl)) })A fact that is missing when it is needed throws, naming the chain: stating a gasLimit needs the
eth_call behaviour and fails when the client is built, and packing a chunk as initcode needs the
initcode limit and fails on the first read. An override-delivered read with no gasLimit needs
neither, so it works on a chain carrying nothing — which means a provider that stops honouring state
overrides can surface the error later than the code that caused it.
This needs viem 2.43 or newer, where extendSchema arrives.
Key-value stores. Each implements the Store interface:
interface Store {
get(key: string): MaybePromise<Buffer[] | null>
set(key: string, value: Buffer[]): MaybePromise<void>
delete(key: string): MaybePromise<void>
flush(): MaybePromise<void>
}| Store | Import | Description |
|---|---|---|
LruStore |
@morpho-org/viem-dlc/stores |
LRU cache with configurable byte-size limit |
TtlStore |
@morpho-org/viem-dlc/stores |
Wraps any store with an absolute per-entry TTL — bounds how long a warm tier may diverge from a fresher source behind it |
MemoryStore |
@morpho-org/viem-dlc/stores |
Simple in-memory Map (prefer LruStore) |
HierarchicalStore |
@morpho-org/viem-dlc/stores |
Layered stores — reads fall through, writes fan out |
ThrottledStore |
@morpho-org/viem-dlc/stores |
Rate-limits writes, coalescing bursts per key under a max staleness timeout |
CompressedStore |
@morpho-org/viem-dlc/stores |
Transparent zstd compression (Node/Bun only) |
NodeFsStore |
@morpho-org/viem-dlc/stores |
One file per key on the local filesystem, atomic writes (Node/Bun only) |
UpstashStore |
@morpho-org/viem-dlc/stores/upstash |
Upstash Redis with automatic value sharding and atomic writes |
VercelStore |
@morpho-org/viem-dlc/stores/vercel |
Vercel Blob; createOptimizedVercelStore returns the same pre-composed stack as Upstash's |
Stores nest, because anything that wraps a store is itself a store. createOptimizedUpstashStore,
exported from @morpho-org/viem-dlc/stores/upstash, returns a pre-composed stack:
LruStore (fast, in-process)
└─ ThrottledStore (coalesces writes)
└─ UpstashStore (durable, remote)
import { createOptimizedUpstashStore } from '@morpho-org/viem-dlc/stores/upstash'
const store = createOptimizedUpstashStore({
maxRequestBytes: 1_000_000,
maxWritesPerSecond: 300,
})TtlStore wraps any store to cap how long its entries stay warm. Fronting a shared remote with a
TTL-bounded in-memory tier keeps reads fast while ensuring a cross-instance write is masked for at
most ttlMs — after which the read falls through to the authoritative remote (a plain LruStore
front would pin the stale copy for the whole process lifetime):
import { HierarchicalStore, LruStore, TtlStore } from '@morpho-org/viem-dlc/stores'
const store = new HierarchicalStore(
[new TtlStore(new LruStore({ maxBytes: 100_000_000 }), { ttlMs: 60_000 }), remote],
{ populateOnMiss: true },
)Replaces viem's getLogs, adding cache-layer search pre-filtering and streaming reduce. It
needs a client whose transport uses the cache() wrapper — that is, one whose rpcSchema is
CacheSchema.
search is a regex matched against raw NDJSON before parsing — use hex-encoded values
(address fragments, topic prefixes) to skip expensive JSON.parse calls on irrelevant batches.
reduce folds over decoded logs in order, keeping memory proportional to the accumulator
rather than the full result set.
import { parseAbiItem } from 'viem'
import { getLogs2 } from '@morpho-org/viem-dlc/actions'
const logs = await getLogs2(client, {
address: '0x...',
event: parseAbiItem('event Transfer(address indexed, address indexed, uint256)'),
fromBlock: 18_000_000n,
toBlock: 19_000_000n,
search: 'deadbeef',
reduce: (acc, log) => {
acc.push(log) // log.args is already decoded
return acc
},
})Reads a paginated lens: the lens's per-item function, called once per element
of args through the deployless or cache transport. Takes the same deployless-factory
descriptor as viem's readContract (abi, address, factory, factoryData) plus the policy
options; returns { results, skipped }, with results typed from the per-item function's return
type and skipped the indices into args that were not served.
import { readLens } from '@morpho-org/viem-dlc/actions'
const { results, skipped } = await readLens(client, {
...healthLens.with(MORPHO), // abi, address, factory, factoryData
functionName: 'healthOf', // f(T) returns (U), one parameter, one value
args: inputs, // T[]
batch: { compress: true },
cache: { blobKey: 'blue-health', ttl: 60_000 },
})A partial result is a successful response: skipped merges elements the lens declined
(its per-item call reverted), elements declined client-side for size, and elements
that ran out of gas even when retried alone. Check it if you need every element.
The lower-level marker readLens attaches for you: a stateOverride entry that tells the
deployless or cache transport to treat a deployless eth_call as a paginated lens read. The
call is encoded against the array-shaped fragment f(T[]) returns (U[] results, uint256[] skipped),
which never exists on-chain; arrayifiedAbi derives it from the per-item function. Use it when you
want plain readContract/call instead of readLens. Element bytes round-trip through the cache
untouched, so tuples, nested arrays, and other complex element types are supported.
policy(opts: {
abi: AbiFunction // arrayifiedAbi(itemFragment)
batch?: {
compress?: boolean
gas?: { fixed: number; item: { avg: number; stddev?: number } }
continuations?: 'fill' | 'eager'
envelope?: 'initcode' | 'override'
}
cache?: {
blobKey: string
ttl: number
delta?: number
}
})opts.abi— the array-shaped fragment fromarrayifiedAbi. Build it from the per-item fragment in the contract's real ABI: the transport derives the per-item selector from it, and a selector the lens does not implement fails as a page that skips every element.opts.batch— optional batching config. What bounds a chunk's bytes is not here: the chain states its initcode limit and the transport states the provider'sbatchSize.opts.batch.compress— FastLZ-compress calldata on the wire, so more elements fit per chunk at the cost of encoding time and decompression gas. The envelope decompresses element by element as it attempts them, so a highly compressible chunk pages like any other and costs nothing before its first element.opts.batch.gas— the lens's cost, in the units the wide event reports it:fixedfromfixed_gas, what a frame spends before its first attempt less the copy of the chunk's own bytes,item.avganditem.stddevfromitem_gas_avganditem_gas_stddev. Together with the transport'sgasLimitit sizes the opening wave: a chunk is as many elements as fit the cap after the calldata's intrinsic gas andfixed, with the same headroom for the spread that continuations keep. Every later chunk is sized from what the pages report, so this only matters until the first attempt has been costed: understating the cost costs one continuation, overstating it costs extra parallel requests. A property of the lens, so one value serves every provider and chain.opts.batch.continuations— when the elements a page did not reach are re-sent.fill(the default) pools them across pages: a full page's worth goes at once, the remainder once no earlier chunk that could still add to it is in flight, so small tails from many pages travel together.eagersends every tail as soon as its page lands: more requests, no waiting. Anything else reads asfill.opts.batch.envelope— how a chunk reaches the node.initcode(the default) creates the envelope with the elements trailing it, bounded by the chain's initcode cap.overridecalls the envelope at a fixed address placed byeth_call's state-override parameter, so the frame's gas is the only bound; a provider that does not honour overrides is detected on the opening wave and the range re-fetched as initcode, at the cost of one wasted wave per request, whichoverride_fallbacks_unsupportedreports. It pays only when bytes bind, which the wide event says:(gas_limit_observed − fixed_gas) / item_gas_avgwell aboveelements_requested / nominal_batches.opts.cache— optional cache config, honored bycache(...)only. If omitted, or when used withdeployless(...),batchis still honored without caching.opts.cache.blobKey— identifies the backing store blob. Requests with the sameblobKeyshare storage; differentblobKeys are isolated into different blobs.opts.cache.ttl— maximum age in milliseconds before a cached entry is considered stale.- Semantic requirement — the per-item function must be elementwise: each served value, and each decline, depends only on its own element plus shared chain state, never on other elements, their multiplicity, their order, or the gas the frame happened to have.
opts.cache.delta— XFetch early-refresh scale in milliseconds. On each freshness check the handler samplesu ~ Uniform(0, 1]and treats the entry as stale onceage - delta * ln(u) >= ttl, so entries may refresh up to severaldeltabeforettlbut never later. Desynchronizes refreshes across many keys populated together, avoiding stampedes. Based on Vattani et al., "Optimal Probabilistic Cache Stampede Prevention" (2015), assuming constant recompute cost. Defaults to 0 (disabled).
The cache derives keys from (targetTo, factory, factoryData, selector, inputElement),
so repeat elements collapse into a single blob entry and novel elements are appended to
the blob on the next fetch. The handler rejects every tx envelope field besides data,
including from, gas and value.
A lens is one view function over one element. That is the whole contract:
contract BlueHealthLens {
IMorpho immutable morpho;
constructor(IMorpho _morpho) { morpho = _morpho; }
function healthOf(Input calldata x) external view returns (Health memory) {
Market memory m = morpho.market(x.id);
require(m.lastUpdate != 0); // permanent condition → skipped
return _health(x.id, m, x.borrower);
}
}The envelope — the bytecode this package puts in front of every deployless call — reads the element
array, calls the per-item function once per element in its own frame with all remaining gas,
and deposits each result straight into the response. Per element, exactly one of three things
happens. The call returns: the result is kept. It reverts (any reason, any data): the
index goes to skipped — so keep per-item reverts to conditions that are permanent, since a
broken dependency is skipped the same way, and the revert reason is not surfaced. It runs out
of gas: EIP-150 guarantees the envelope keeps 1/64 of what it forwarded, which is enough to
report that in-band and stop; the transport retries the element once on its own, where it holds
the largest frame a node can grant, and only if it dies there too does it land in the caller's
skipped. Before each attempt the envelope also checks that the frame can pay for the memory the
attempt would touch and still report an outcome, priced from the fee schedule; below that, it
stops (or, for element 0, reports it unresolved without attempting).
So a frame never dies mid-page, and the prologue grows with the chunk only by the copy of its
bytes, which the packer prices; a constructor too heavy for a node's cap is reported as an error
rather than halved.
No element type needs a number from the author: static sizes come from the ABI, dynamic inputs
carry their length on the wire, and dynamic results carry theirs in returndata. The envelope
refuses an ill-formed result (MalformedResult, surfaced as a protocol error rather than halved).
Shared work goes in the constructor. Item frames share no memory, but the counterfactual
deploy runs the constructor inside the same eth_call: immutables hold value types, and storage
written in the constructor is readable from every per-item call. EIP-2929 warmth is per
transaction, so the first element to touch a market's storage warms it for all later elements in
the chunk. The constructor runs once per chunk in the prologue, so keep it bounded: one that
exhausts the node's cap fails the request outright.
What the envelope cannot enforce, and the lens must still honor: skips are deterministic (a
revert means invalid input or a permanently failing element, never something more gas would pass)
and values are batching-invariant (neither a served value nor a decline may depend on
position, batch composition, or gasleft()).
What the caller sees. readLens returns { results, skipped } typed from the per-item
function. Through plain viem, the chunked calls aggregate into one page over the whole input, in
the shape arrayifiedAbi declares, so readContract and decodeFunctionResult work too:
const pageAbi = arrayifiedAbi(getAbiItem({ abi: healthLens.abi, name: 'healthOf' }))
const [results, skipped] = await readContract(client, {
...healthLens.with(MORPHO),
abi: [pageAbi], // after the spread: the real ABI has no array function
functionName: 'healthOf',
args: [inputs],
stateOverride: [policy({ abi: pageAbi })],
})skipped is rebased onto the caller's input, and expands across deduplicated inputs, so its
indices always address the array you passed. Elements that ran out of gas even alone depend on
the node's eth_call gas cap, so a provider with a higher cap might serve them;
elements_unresolved counts them in the wide event.
Finds the block a contract was deployed at, by binary search over getCode.
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { getDeploymentBlockNumber } from '@morpho-org/viem-dlc/actions'
const client = createPublicClient({ chain: mainnet, transport: http() })
const block = await getDeploymentBlockNumber(client, {
address: '0x...',
})Exported from @morpho-org/viem-dlc/utils:
divideBlockRange/mergeBlockRanges/halveBlockRange— block range manipulationresolveBlockNumber/extractRangeFromFilter/isInBlockRange— block number helpersclassifyBlockRangeError— classify RPC errors as range-related, timeout-like, or neithercreateCoalescingMutex— per-resource leader/follower batchingcreateTokenBucket/createRateLimit— rate limiting primitivescyrb64Hash— fast string hashingstringify/parse/estimateUtf8Bytes— JSON serialization with bigint supportpick/omit— object helpersmeasureUtf8Bytes/shardString— string utilities
The envelope's codecs are exported separately, from @morpho-org/viem-dlc/utils/deployless, for
building fixtures and mocks: everything needed to read a chunk off a request the transports sent and
to answer it the way a node running the envelope would.
unwrapDeploylessFactoryCall/encodeEnvelopeArgs/envelopeConfig/deliveryParams— the outbound request, both deliveriesdecodeEnvelopeRevert,FACTORY_BYTECODE_REVERT,ENVELOPE_ADDRESS,OK_SENTINELand the other sentinels — what the envelope reverts with, and what identifies itarrayifiedAbi/resolveArrayFunction/abiToArray/arrayToAbi— the caller's arrayarrayToWire/wireToArray/streamToPage/pageToStream/pageToAbi— the envelope's wire and the page it reverts
The packer, the cost model and the FastLZ codec stay internal: their shapes follow the implementation rather than the wire.