@livo-build/runtime
v0.2.21
Published
Livo runtime — chain signing/reads, D1 state, and logging for keepers, servers, and bots.
Readme
@livo-build/runtime
The standard library for Livo compute targets that bundle npm deps at deploy —
keepers (cron Workers) and bots (Telegram/Twitter Workers). (Servers /
Durable Objects deploy as a single un-bundled module today, so they can't import
this yet — use fetch + Web Crypto there; their INDEXER_<NAME>_URL env binding is
still injected.)
It deletes the most-repeated, most-error-prone code these targets hand-roll: signing and sending on-chain transactions, reading contract state, querying the project's subgraph, the Telegram webhook dance, talking to D1, and basic logging/retry. No more hand-bundling secp256k1 to fit a single Worker module.
At a glance: Chain (read/sign/send + RPC failover), Relayer (managed bot
signing), Wallet (per-user custodial wallets, server-side custody),
bindContracts (call contracts by name), Indexer (typed subgraph
queries), Telegram (webhook plumbing in one call), Store (D1 KV + idempotency),
verifyMessage (prove wallet ownership), requireSecret, queue, retry, log.
import { Chain, Store, log } from "@livo-build/runtime";
export default {
async scheduled(event, env, ctx) {
const chain = new Chain(env); // reads RPC_URL + KEEPER_PRIVATE_KEY from env
const count = await chain.call(FORUM, "topicCount()(uint256)");
const hash = await chain.send({ address: FORUM, abi: FORUM_ABI, functionName: "createPost", args: [count, "gm"] });
const receipt = await chain.waitForReceipt(hash);
log.info("posted", { hash, status: receipt.status });
},
};Delivery model — explicit import, registry-installed, bundled at deploy
You write normal imported code that is locally runnable and type-checked. The Livo deploy step produces the self-contained single-module Worker the platform requires. There are no ambient globals — what you read in the source is what runs.
@livo-build/runtime is published to npm (like @livo-build/livo). A
keeper/bot/server that imports it ships a package.json declaring the dependency:
- On deploy, the presence of a
package.jsonwith deps routes the target through the platform's existing build container (npm install+esbuild), which resolves@livo-build/runtimefrom the registry and bundles it — plus its audited@noblecrypto — into one self-contained Worker module. No hand-bundling. - Targets with no
package.jsondeploy exactly as before — the build step is opt-in by the presence of a manifest. Existing keepers keep working untouched. - Versions pin in
package.json("@livo-build/runtime": "^0.2.3"), so a runtime update can't silently change a working keeper.
Scaffold the common path with create_keeper --template runtime-keeper.
Layer 1 — Chain
Reads need no key; writes need a signer key on env.
const chain = new Chain(env, {
rpcUrlSecret: "RPC_URL", // default
signerKeySecret: "KEEPER_PRIVATE_KEY", // omit for read-only
});
// reads
await chain.call(address, "balanceOf(address)(uint256)", [who]);
await chain.readContract({ address, abi, functionName, args });
await chain.getLogs({ address, event: "Transfer(address indexed from, address indexed to, uint256 value)", args: { from }, fromBlock });
await chain.getBalance(addr);
chain.address; // signer address (null when read-only)
// writes — encode → nonce(pending) → fees → estimateGas(+20%) → sign EIP-1559 → broadcast
const hash = await chain.send({ address, abi, functionName, args });
const receipt = await chain.waitForReceipt(hash, { timeoutMs, pollMs });
// reliable write — nonce resolved at broadcast, retry-on-collision + replace-by-fee
const h = await chain.sendReliable({ address, abi, functionName, args, confirmMs: 30_000 });
// batch reads — ONE eth_call via Multicall3 (allowFailure defaults true → null on revert)
const [supply, mine] = await chain.multicall([
{ address: token, abi, functionName: "totalSupply" },
{ address: token, abi, functionName: "balanceOf", args: [chain.address] },
]);
// deploy a contract — to:null creation, waits for the receipt's contractAddress
const { address: deployed } = await chain.deploy({ abi, bytecode, args: [param] });
// escape hatches
chain.encodeFunction(abi, fn, args); // 0x calldata
chain.signTx(txFields); // raw 0x EIP-1559 tx
chain.rpc(method, params); // raw JSON-RPCDefaults (configurable): nonce pending; tip = 1.5 gwei; maxFee = baseFee*2 +
tip; estimateGas * 1.2 with a 500_000 fallback when the node reverts the
estimate. A funding-less signer fails loudly: "signer wallet 0x… can't afford
this tx — needs ~N wei, has M wei."
Encoding and signing are validated byte-for-byte against viem in CI
(test/abi.test.ts, test/tx.test.ts). EIP-1559 (type-2) only for v1.
Layer 2 — Contract bindings
sync_contract_bindings emits a pure-data bindings module —
keepers/_livo/contracts.js — from the same canonical pointer (pickCanonical)
the frontend's interface/src/livo/contracts.ts uses, so runtime and UI can
never watch different deployments. Feed it to bindContracts for
name-addressable, typed contract handles:
import { Chain, bindContracts } from "@livo-build/runtime";
import { addresses, abis } from "../_livo/contracts.js"; // generated, canonical
const contracts = bindContracts(addresses, abis);
const forum = contracts.Forum.connect(chain); // address resolved from the canonical registry
await forum.read.topicCount();
const hash = await forum.write.createPost(topicId, body);(The module is pure data and dependency-free so it resolves in the deploy build
container without the keeper's node_modules. @livo-build/runtime/contracts
exports bindContracts and the binding types.)
RPC failover
Chain accepts several endpoints and rotates past transport failures (network
error, 5xx, 429) — a deterministic chain error (revert/nonce) is surfaced, not
retried elsewhere:
new Chain(env, { rpcUrls: ["https://a", "https://b"] });
new Chain(env); // or set RPC_URL to a comma/whitespace-separated listIndexer — query the project's subgraph
INDEXER_<NAME>_URL is injected at deploy as the subgraph's stable live
alias, so the worker never holds a version-pinned URL that breaks on the next
sync_indexers/reindex:
import { indexer } from "@livo-build/runtime";
const { keepers } = await indexer(env, "hearth").query(
`query($m:Int!){ keepers(where:{count_gte:$m}){ id count } }`, { m: 3 });
await indexer(env, "hearth").meta(); // { block, hasIndexingErrors }Telegram — webhook plumbing in one call
import { Telegram } from "@livo-build/runtime";
export default {
fetch(req, env) {
return new Telegram(env).handleUpdate(req, (msg) =>
msg.text === "/start" ? "👋 hi " + msg.handle : "you said " + msg.text);
},
};handleUpdate verifies the WEBHOOK_SECRET secret-token, short-circuits Livo's
__livo_test harness (returns the computed reply without calling Telegram),
parses the update, and sends the reply. Lower-level verify / parse /
sendMessage / setMyCommands are exposed too.
Layer 3 — State + utilities
const store = new Store(env.DB); // typed KV over D1; auto-creates `livo_kv`
await store.get("heartbeat"); // string | null
await store.set("heartbeat", "3");
await store.getJSON<Config>("config");
await store.setJSON("config", obj);
// Idempotency: true only the FIRST time a key is seen — a retried cron tick or a
// redelivered webhook won't double-fire.
if (!(await store.once(`block:${n}`))) return;
log.info("posted", { tx, topicId }); // structured, consistently prefixed
log.error("send failed", err);
import { requireSecret, retry } from "@livo-build/runtime";
const apiKey = requireSecret(env, "SOME_API_KEY"); // loud error if missing, not silent undefined
await retry(() => chain.send(...), { tries: 3, backoffMs: 500 }); // flaky RPCsStore replaces the hand-rolled CREATE TABLE IF NOT EXISTS kv (k,v). The table
is livo_kv(k TEXT PRIMARY KEY, v TEXT, updated_at INTEGER) — stable and
documented.
Watching events — watchLogs
A Worker has no long-lived process, so this is catch-up per scheduled tick, not
a websocket subscription. It reads new logs since a Store-persisted block cursor,
up to the (confirmed) head, in chunked eth_getLogs windows:
import { watchLogs } from "@livo-build/runtime";
await watchLogs(chain, store, {
event: "Transfer(address indexed from, address indexed to, uint256 value)",
address: token, // optional filter
cursorKey: "transfers", // namespaced Store cursor
confirmations: 2n, // reorg safety (default 0)
chunkSize: 2000n, // match your RPC's eth_getLogs range cap
onLogs: async (logs, range) => { /* idempotent! at-least-once delivery */ },
});The cursor advances after each successful onLogs, so a throw retries that
window on the next tick — onLogs must be idempotent. First run defaults to "from
head" (new logs only); pass fromBlock to backfill.
Bots — Relayer
Relayer extends Chain: same read/write/multicall surface, but it signs with the
custodied RELAYER_PRIVATE_KEY (not the keeper key) and — when the platform
injects RELAYER_NONCE_URL + RELAYER_NONCE_TOKEN — serializes nonces through
Convex so concurrent bot invocations sharing one managed key don't collide
(falling back to RPC pending otherwise).
import { Relayer, log } from "@livo-build/runtime";
const relayer = new Relayer(env); // RELAYER_PRIVATE_KEY
const hash = await relayer.send({ address, abi, functionName, args });Bots — Wallet (per-user custodial wallets)
Wallet gives each end user their own wallet(s). The bot signs on a user's
behalf without ever holding key material: every call hits the platform
(WALLET_API_URL + WALLET_API_TOKEN, auto-injected), which custodies a
per-(project, user) seed and signs server-side. Generated wallets derive from
the user's own seed (isolated from the project's platform mnemonic and from other
users); imported wallets store an encrypted raw key. This is the sanctioned
wallet primitive — don't hand-roll key storage. Scaffold with create_bot
--template wallet-bot (the default).
import { Wallet } from "@livo-build/runtime";
const acct = new Wallet(env).user(msg.from.id); // a handle for one end-user
await acct.address(); // active wallet (auto-created on first call)
await acct.list(); // [{ label, address, active, imported }]
await acct.create("trading"); // new HD wallet from the user's own seed
await acct.import("0x<key>", "main"); // bring your own key
await acct.use("trading"); // switch active
const hash = await acct.send({ address, abi, functionName, args }); // platform signsProve wallet ownership — verifyMessage
Verify a "prove you own this wallet" signature server-side (EIP-191 personal_sign,
the basis of wallet-link / SIWE login) — no hand-rolled secp256k1. The pattern: a website
has the user connect a wallet and personal_sign a nonce; a bot/keeper then verifies it and
links the recovered address to the user's account.
import { verifyMessage, recoverMessageAddress } from "@livo-build/runtime";
// true iff `address` produced `signature` over `message` (never throws)
if (verifyMessage({ address, message: "Link my wallet · nonce: " + nonce, signature })) {
// ownership proven — link `address` to the user
}
recoverMessageAddress(message, signature); // → the signer's checksummed addressScaffold the full flow (bot serves the connect+sign page, verifies, links, and gates a private
group; a keeper boots members who fall below) with create_bot --template wallet-link-bot.
Queues
Publish to a per-project queue from any deployable that declares produces: [name]
(the platform injects QUEUE_<NAME>_URL + QUEUE_<NAME>_TOKEN):
import { queue } from "@livo-build/runtime"; // or new Queue(env, "settler")
await queue(env, "settler").send({ orderId });
await queue(env, "settler").sendBatch([{ a: 1 }, { a: 2 }]);Local testing
Everything runs in Node with a mocked env — no platform involved. See
test/keeper-harness.test.ts for the template: a fake JSON-RPC handler, an
in-memory D1, and a real keeper handler driven end-to-end.
npm test # vitest: viem-equivalence + keeper harness
npm run build # tsc → dist/*.js + .d.ts (what npm consumers install)Out of scope (v1)
Legacy pre-1559 txs, multi-chain abstraction beyond a chainId, account abstraction / paymasters, anything non-EVM. Added later only when a second real use case demands it.
Agent-facing copy of this reference lives at the MCP resource
livo://skill/runtime;test/skill-coverage.test.tsfails CI if a public export here isn't documented there, so the two can't drift.
