@livo-build/runtime 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -8
- package/dist/chain.d.ts +62 -5
- package/dist/chain.js +126 -21
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.js +15 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.js +12 -1
- package/dist/indexer.d.ts +35 -0
- package/dist/indexer.js +73 -0
- package/dist/multicall.d.ts +30 -0
- package/dist/multicall.js +65 -0
- package/dist/queue.d.ts +27 -0
- package/dist/queue.js +49 -0
- package/dist/secret.d.ts +15 -0
- package/dist/secret.js +40 -0
- package/dist/store.d.ts +11 -0
- package/dist/store.js +24 -0
- package/dist/telegram.d.ts +63 -0
- package/dist/telegram.js +115 -0
- package/dist/watch.d.ts +44 -0
- package/dist/watch.js +46 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
# @livo-build/runtime
|
|
2
2
|
|
|
3
|
-
The standard library
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The standard library for Livo compute targets that bundle npm deps at deploy —
|
|
4
|
+
**keepers** (cron Workers) and **bots** (Telegram/Twitter Workers). (Servers /
|
|
5
|
+
Durable Objects deploy as a single un-bundled module today, so they can't import
|
|
6
|
+
this yet — use fetch + Web Crypto there; their `INDEXER_<NAME>_URL` env binding is
|
|
7
|
+
still injected.)
|
|
6
8
|
|
|
7
9
|
It deletes the most-repeated, most-error-prone code these targets hand-roll:
|
|
8
|
-
signing and sending on-chain transactions, reading contract state,
|
|
9
|
-
|
|
10
|
-
module.
|
|
10
|
+
signing and sending on-chain transactions, reading contract state, querying the
|
|
11
|
+
project's subgraph, the Telegram webhook dance, talking to D1, and basic
|
|
12
|
+
logging/retry. No more hand-bundling secp256k1 to fit a single Worker module.
|
|
13
|
+
|
|
14
|
+
At a glance: `Chain` (read/sign/send + RPC failover), `Relayer` (managed bot
|
|
15
|
+
signing), `bindContracts` (call contracts by name), `Indexer` (typed subgraph
|
|
16
|
+
queries), `Telegram` (webhook plumbing in one call), `Store` (D1 KV + idempotency),
|
|
17
|
+
`requireSecret`, `queue`, `retry`, `log`.
|
|
11
18
|
|
|
12
19
|
```ts
|
|
13
20
|
import { Chain, Store, log } from "@livo-build/runtime";
|
|
@@ -39,7 +46,7 @@ keeper/bot/server that imports it ships a `package.json` declaring the dependenc
|
|
|
39
46
|
audited `@noble` crypto — into one self-contained Worker module. No hand-bundling.
|
|
40
47
|
- Targets with **no `package.json`** deploy exactly as before — the build step is
|
|
41
48
|
opt-in by the presence of a manifest. Existing keepers keep working untouched.
|
|
42
|
-
- Versions pin in `package.json` (`"@livo-build/runtime": "^0.
|
|
49
|
+
- Versions pin in `package.json` (`"@livo-build/runtime": "^0.2.2"`), so a runtime
|
|
43
50
|
update can't silently change a working keeper.
|
|
44
51
|
|
|
45
52
|
Scaffold the common path with `create_keeper --template runtime-keeper`.
|
|
@@ -65,6 +72,18 @@ chain.address; // signer address (null when read-only)
|
|
|
65
72
|
const hash = await chain.send({ address, abi, functionName, args });
|
|
66
73
|
const receipt = await chain.waitForReceipt(hash, { timeoutMs, pollMs });
|
|
67
74
|
|
|
75
|
+
// reliable write — nonce resolved at broadcast, retry-on-collision + replace-by-fee
|
|
76
|
+
const h = await chain.sendReliable({ address, abi, functionName, args, confirmMs: 30_000 });
|
|
77
|
+
|
|
78
|
+
// batch reads — ONE eth_call via Multicall3 (allowFailure defaults true → null on revert)
|
|
79
|
+
const [supply, mine] = await chain.multicall([
|
|
80
|
+
{ address: token, abi, functionName: "totalSupply" },
|
|
81
|
+
{ address: token, abi, functionName: "balanceOf", args: [chain.address] },
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// deploy a contract — to:null creation, waits for the receipt's contractAddress
|
|
85
|
+
const { address: deployed } = await chain.deploy({ abi, bytecode, args: [param] });
|
|
86
|
+
|
|
68
87
|
// escape hatches
|
|
69
88
|
chain.encodeFunction(abi, fn, args); // 0x calldata
|
|
70
89
|
chain.signTx(txFields); // raw 0x EIP-1559 tx
|
|
@@ -101,6 +120,47 @@ const hash = await forum.write.createPost(topicId, body);
|
|
|
101
120
|
container without the keeper's `node_modules`. `@livo-build/runtime/contracts`
|
|
102
121
|
exports `bindContracts` and the binding types.)
|
|
103
122
|
|
|
123
|
+
### RPC failover
|
|
124
|
+
|
|
125
|
+
`Chain` accepts several endpoints and rotates past transport failures (network
|
|
126
|
+
error, 5xx, 429) — a deterministic chain error (revert/nonce) is surfaced, not
|
|
127
|
+
retried elsewhere:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
new Chain(env, { rpcUrls: ["https://a", "https://b"] });
|
|
131
|
+
new Chain(env); // or set RPC_URL to a comma/whitespace-separated list
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Indexer — query the project's subgraph
|
|
135
|
+
|
|
136
|
+
`INDEXER_<NAME>_URL` is injected at deploy as the subgraph's **stable `live`
|
|
137
|
+
alias**, so the worker never holds a version-pinned URL that breaks on the next
|
|
138
|
+
`sync_indexers`/`reindex`:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { indexer } from "@livo-build/runtime";
|
|
142
|
+
const { keepers } = await indexer(env, "hearth").query(
|
|
143
|
+
`query($m:Int!){ keepers(where:{count_gte:$m}){ id count } }`, { m: 3 });
|
|
144
|
+
await indexer(env, "hearth").meta(); // { block, hasIndexingErrors }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Telegram — webhook plumbing in one call
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { Telegram } from "@livo-build/runtime";
|
|
151
|
+
export default {
|
|
152
|
+
fetch(req, env) {
|
|
153
|
+
return new Telegram(env).handleUpdate(req, (msg) =>
|
|
154
|
+
msg.text === "/start" ? "👋 hi " + msg.handle : "you said " + msg.text);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`handleUpdate` verifies the `WEBHOOK_SECRET` secret-token, short-circuits Livo's
|
|
160
|
+
`__livo_test` harness (returns the computed reply without calling Telegram),
|
|
161
|
+
parses the update, and sends the reply. Lower-level `verify` / `parse` /
|
|
162
|
+
`sendMessage` / `setMyCommands` are exposed too.
|
|
163
|
+
|
|
104
164
|
## Layer 3 — State + utilities
|
|
105
165
|
|
|
106
166
|
```ts
|
|
@@ -110,10 +170,15 @@ await store.set("heartbeat", "3");
|
|
|
110
170
|
await store.getJSON<Config>("config");
|
|
111
171
|
await store.setJSON("config", obj);
|
|
112
172
|
|
|
173
|
+
// Idempotency: true only the FIRST time a key is seen — a retried cron tick or a
|
|
174
|
+
// redelivered webhook won't double-fire.
|
|
175
|
+
if (!(await store.once(`block:${n}`))) return;
|
|
176
|
+
|
|
113
177
|
log.info("posted", { tx, topicId }); // structured, consistently prefixed
|
|
114
178
|
log.error("send failed", err);
|
|
115
179
|
|
|
116
|
-
import { retry } from "@livo-build/runtime";
|
|
180
|
+
import { requireSecret, retry } from "@livo-build/runtime";
|
|
181
|
+
const apiKey = requireSecret(env, "SOME_API_KEY"); // loud error if missing, not silent undefined
|
|
117
182
|
await retry(() => chain.send(...), { tries: 3, backoffMs: 500 }); // flaky RPCs
|
|
118
183
|
```
|
|
119
184
|
|
|
@@ -121,6 +186,55 @@ await retry(() => chain.send(...), { tries: 3, backoffMs: 500 }); // flaky RPCs
|
|
|
121
186
|
is `livo_kv(k TEXT PRIMARY KEY, v TEXT, updated_at INTEGER)` — stable and
|
|
122
187
|
documented.
|
|
123
188
|
|
|
189
|
+
## Watching events — `watchLogs`
|
|
190
|
+
|
|
191
|
+
A Worker has no long-lived process, so this is **catch-up per scheduled tick**, not
|
|
192
|
+
a websocket subscription. It reads new logs since a `Store`-persisted block cursor,
|
|
193
|
+
up to the (confirmed) head, in chunked `eth_getLogs` windows:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { watchLogs } from "@livo-build/runtime";
|
|
197
|
+
|
|
198
|
+
await watchLogs(chain, store, {
|
|
199
|
+
event: "Transfer(address indexed from, address indexed to, uint256 value)",
|
|
200
|
+
address: token, // optional filter
|
|
201
|
+
cursorKey: "transfers", // namespaced Store cursor
|
|
202
|
+
confirmations: 2n, // reorg safety (default 0)
|
|
203
|
+
chunkSize: 2000n, // match your RPC's eth_getLogs range cap
|
|
204
|
+
onLogs: async (logs, range) => { /* idempotent! at-least-once delivery */ },
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The cursor advances **after** each successful `onLogs`, so a throw retries that
|
|
209
|
+
window on the next tick — `onLogs` must be idempotent. First run defaults to "from
|
|
210
|
+
head" (new logs only); pass `fromBlock` to backfill.
|
|
211
|
+
|
|
212
|
+
## Bots — `Relayer`
|
|
213
|
+
|
|
214
|
+
`Relayer extends Chain`: same read/write/multicall surface, but it signs with the
|
|
215
|
+
custodied **`RELAYER_PRIVATE_KEY`** (not the keeper key) and — when the platform
|
|
216
|
+
injects `RELAYER_NONCE_URL` + `RELAYER_NONCE_TOKEN` — serializes nonces through
|
|
217
|
+
Convex so concurrent bot invocations sharing one managed key don't collide
|
|
218
|
+
(falling back to RPC pending otherwise). Scaffold with `create_bot --template
|
|
219
|
+
relayer-bot`.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { Relayer, log } from "@livo-build/runtime";
|
|
223
|
+
const relayer = new Relayer(env); // RELAYER_PRIVATE_KEY
|
|
224
|
+
const hash = await relayer.send({ address, abi, functionName, args });
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Queues
|
|
228
|
+
|
|
229
|
+
Publish to a per-project queue from any deployable that declares `produces: [name]`
|
|
230
|
+
(the platform injects `QUEUE_<NAME>_URL` + `QUEUE_<NAME>_TOKEN`):
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { queue } from "@livo-build/runtime"; // or new Queue(env, "settler")
|
|
234
|
+
await queue(env, "settler").send({ orderId });
|
|
235
|
+
await queue(env, "settler").sendBatch([{ a: 1 }, { a: 2 }]);
|
|
236
|
+
```
|
|
237
|
+
|
|
124
238
|
## Local testing
|
|
125
239
|
|
|
126
240
|
Everything runs in Node with a mocked `env` — no platform involved. See
|
|
@@ -137,3 +251,7 @@ npm run build # tsc → dist/*.js + .d.ts (what npm consumers install)
|
|
|
137
251
|
Legacy pre-1559 txs, multi-chain abstraction beyond a chainId, account
|
|
138
252
|
abstraction / paymasters, anything non-EVM. Added later only when a second real
|
|
139
253
|
use case demands it.
|
|
254
|
+
|
|
255
|
+
> Agent-facing copy of this reference lives at the MCP resource
|
|
256
|
+
> **`livo://skill/runtime`**; `test/skill-coverage.test.ts` fails CI if a public
|
|
257
|
+
> export here isn't documented there, so the two can't drift.
|
package/dist/chain.d.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import { type AbiFunction } from "./abi.js";
|
|
1
|
+
import { type AbiFunction, type AbiParameter } from "./abi.js";
|
|
2
2
|
import { type AbiEvent, type DecodedLog } from "./events.js";
|
|
3
3
|
import { type Eip1559Tx } from "./tx.js";
|
|
4
|
+
import { type MulticallCall, type MulticallOptions } from "./multicall.js";
|
|
4
5
|
import { type Hex } from "./hex.js";
|
|
5
6
|
export declare function isNonceCollision(err: unknown): boolean;
|
|
6
7
|
export interface ChainOptions {
|
|
7
|
-
/** env key holding the JSON-RPC URL. Default "RPC_URL". */
|
|
8
|
+
/** env key holding the JSON-RPC URL(s). One URL, or several comma/whitespace-separated for failover. Default "RPC_URL". */
|
|
8
9
|
rpcUrlSecret?: string;
|
|
9
10
|
/** env key holding the 0x signer private key. Omit for read-only usage. Default "KEEPER_PRIVATE_KEY". */
|
|
10
11
|
signerKeySecret?: string;
|
|
11
12
|
/** Override the RPC URL directly (takes precedence over env). */
|
|
12
13
|
rpcUrl?: string;
|
|
14
|
+
/** Override with several RPC URLs for failover (takes precedence over env + rpcUrl). */
|
|
15
|
+
rpcUrls?: string[];
|
|
13
16
|
/** Override the private key directly (takes precedence over env). */
|
|
14
17
|
privateKey?: string;
|
|
15
18
|
/** Override chainId (otherwise read from the RPC on first send). */
|
|
@@ -72,11 +75,40 @@ export interface Receipt {
|
|
|
72
75
|
contractAddress: Hex | null;
|
|
73
76
|
raw: Record<string, unknown>;
|
|
74
77
|
}
|
|
78
|
+
/** A constructor entry has no `name`, so it doesn't fit AbiFunction — accept either. */
|
|
79
|
+
type ConstructorEntry = {
|
|
80
|
+
type: "constructor";
|
|
81
|
+
inputs?: AbiParameter[];
|
|
82
|
+
stateMutability?: string;
|
|
83
|
+
};
|
|
84
|
+
export interface DeployOptions {
|
|
85
|
+
/** Full contract ABI — only the `constructor` entry is read (for arg encoding). */
|
|
86
|
+
abi: ReadonlyArray<AbiFunction | ConstructorEntry>;
|
|
87
|
+
/** Creation bytecode, 0x-prefixed. */
|
|
88
|
+
bytecode: Hex;
|
|
89
|
+
/** Constructor args, ABI-encoded against the constructor's inputs and appended. */
|
|
90
|
+
args?: unknown[];
|
|
91
|
+
value?: bigint;
|
|
92
|
+
/** Override gas (skips estimateGas — recommended for large contracts). */
|
|
93
|
+
gas?: bigint;
|
|
94
|
+
nonce?: bigint;
|
|
95
|
+
maxFeePerGas?: bigint;
|
|
96
|
+
maxPriorityFeePerGas?: bigint;
|
|
97
|
+
/** Receipt wait timeout, ms. Default 120_000. */
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
/** Receipt poll interval, ms. Default 2_000. */
|
|
100
|
+
pollMs?: number;
|
|
101
|
+
}
|
|
75
102
|
interface MinimalEnv {
|
|
76
103
|
[key: string]: unknown;
|
|
77
104
|
}
|
|
78
105
|
export declare class Chain {
|
|
106
|
+
/** Primary RPC URL (first of `rpcUrls`). Kept for back-compat. */
|
|
79
107
|
readonly rpcUrl: string;
|
|
108
|
+
/** All configured RPC URLs, tried in rotation with failover on transport errors. */
|
|
109
|
+
readonly rpcUrls: string[];
|
|
110
|
+
/** Rotating start offset so load spreads across endpoints. */
|
|
111
|
+
private rpcCursor;
|
|
80
112
|
private readonly privateKey?;
|
|
81
113
|
private _chainId?;
|
|
82
114
|
private readonly opts;
|
|
@@ -85,7 +117,13 @@ export declare class Chain {
|
|
|
85
117
|
/** chainId passed at construction, if any — lets bindings resolve addresses synchronously. */
|
|
86
118
|
readonly configuredChainId?: number;
|
|
87
119
|
constructor(env: MinimalEnv | undefined, options?: ChainOptions);
|
|
88
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Raw JSON-RPC passthrough. Throws on RPC errors with the node's message.
|
|
122
|
+
* With multiple `rpcUrls`, a TRANSPORT failure (network error, 5xx, 429) on one
|
|
123
|
+
* endpoint rotates to the next; a JSON-level `error` (a deterministic chain
|
|
124
|
+
* response, e.g. revert/nonce) is returned as-is — retrying elsewhere wouldn't
|
|
125
|
+
* change it. The start endpoint rotates per call to spread load.
|
|
126
|
+
*/
|
|
89
127
|
rpc<T = unknown>(method: string, params?: unknown[]): Promise<T>;
|
|
90
128
|
getChainId(): Promise<number>;
|
|
91
129
|
getBlockNumber(): Promise<bigint>;
|
|
@@ -100,6 +138,13 @@ export declare class Chain {
|
|
|
100
138
|
readContract(opts: ReadOptions): Promise<unknown>;
|
|
101
139
|
/** Query + decode event logs. */
|
|
102
140
|
getLogs(opts: GetLogsOptions): Promise<DecodedLog[]>;
|
|
141
|
+
/**
|
|
142
|
+
* Batch many contract reads into ONE eth_call via Multicall3 (aggregate3).
|
|
143
|
+
* Results are positionally aligned with `calls`; a reverting call yields null
|
|
144
|
+
* when allowFailure (default true) or throws otherwise. All calls observe the
|
|
145
|
+
* same block, so the batch is atomic/consistent.
|
|
146
|
+
*/
|
|
147
|
+
multicall(calls: MulticallCall[], opts?: MulticallOptions): Promise<(unknown | null)[]>;
|
|
103
148
|
/** Low-level: 0x calldata for a function call (no broadcast). */
|
|
104
149
|
encodeFunction(abi: AbiFunction[], functionName: string, args?: unknown[]): Hex;
|
|
105
150
|
/** Low-level: sign a fully-specified EIP-1559 tx, returning the raw 0x tx. */
|
|
@@ -109,6 +154,17 @@ export declare class Chain {
|
|
|
109
154
|
* Returns the transaction hash. Fails loudly on a funding-less signer.
|
|
110
155
|
*/
|
|
111
156
|
send(opts: SendOptions): Promise<Hex>;
|
|
157
|
+
/**
|
|
158
|
+
* Deploy a contract: build a creation tx (to:null, data = bytecode ++ encoded
|
|
159
|
+
* constructor args), sign + broadcast on the same nonce/fee/gas path as send(),
|
|
160
|
+
* wait for the receipt, and return the deployed address. Note: the default
|
|
161
|
+
* fallbackGas (500k) is low for real contracts — pass `gas` for large ones.
|
|
162
|
+
*/
|
|
163
|
+
deploy(opts: DeployOptions): Promise<{
|
|
164
|
+
address: Hex;
|
|
165
|
+
hash: Hex;
|
|
166
|
+
receipt: Receipt;
|
|
167
|
+
}>;
|
|
112
168
|
/**
|
|
113
169
|
* Optimistic reliable send (docs/WALLET-SPEC.md "optimistic, assigned at
|
|
114
170
|
* broadcast"). Resolves the nonce at broadcast (never pre-allocated), retries on a
|
|
@@ -132,9 +188,10 @@ export declare class Chain {
|
|
|
132
188
|
maxFeePerGas: bigint;
|
|
133
189
|
maxPriorityFeePerGas: bigint;
|
|
134
190
|
}>;
|
|
135
|
-
/** estimateGas with headroom and a safe fallback when the node reverts the estimate.
|
|
191
|
+
/** estimateGas with headroom and a safe fallback when the node reverts the estimate.
|
|
192
|
+
* `to: null` estimates a contract-creation tx (the `to` field is omitted). */
|
|
136
193
|
estimateGas(tx: {
|
|
137
|
-
to: Hex;
|
|
194
|
+
to: Hex | null;
|
|
138
195
|
from: Hex;
|
|
139
196
|
data: Hex;
|
|
140
197
|
value: bigint;
|
package/dist/chain.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// zero hand-rolled crypto or ABI. Constructed from the keeper/server/bot `env`:
|
|
3
3
|
// reads RPC_URL and (for writes) a signing key, both as env bindings injected at
|
|
4
4
|
// deploy. Reads need no key; writes derive the signer address from the key.
|
|
5
|
-
import { decodeFunctionResult, encodeFunctionData, findFunction, functionFromSignature, } from "./abi.js";
|
|
5
|
+
import { decodeFunctionResult, encodeFunctionData, encodeParameters, findFunction, functionFromSignature, } from "./abi.js";
|
|
6
6
|
import { decodeLog, encodeFilterTopics, } from "./events.js";
|
|
7
7
|
import { privateKeyToAddress, signTransaction, transactionHash, } from "./tx.js";
|
|
8
|
-
import {
|
|
8
|
+
import { multicall as multicallImpl } from "./multicall.js";
|
|
9
|
+
import { bytesToHex, concatBytes, hexToBytes } from "./hex.js";
|
|
9
10
|
const GWEI = 1000000000n;
|
|
10
11
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
12
|
// Classify an RPC error as a nonce collision — another sender took the nonce we
|
|
@@ -20,8 +21,16 @@ export function isNonceCollision(err) {
|
|
|
20
21
|
m.includes("known transaction") ||
|
|
21
22
|
m.includes("replacement transaction underpriced"));
|
|
22
23
|
}
|
|
24
|
+
function findConstructor(abi) {
|
|
25
|
+
return abi.find((e) => e.type === "constructor");
|
|
26
|
+
}
|
|
23
27
|
export class Chain {
|
|
28
|
+
/** Primary RPC URL (first of `rpcUrls`). Kept for back-compat. */
|
|
24
29
|
rpcUrl;
|
|
30
|
+
/** All configured RPC URLs, tried in rotation with failover on transport errors. */
|
|
31
|
+
rpcUrls;
|
|
32
|
+
/** Rotating start offset so load spreads across endpoints. */
|
|
33
|
+
rpcCursor = 0;
|
|
25
34
|
privateKey;
|
|
26
35
|
_chainId;
|
|
27
36
|
opts;
|
|
@@ -32,10 +41,20 @@ export class Chain {
|
|
|
32
41
|
constructor(env, options = {}) {
|
|
33
42
|
const rpcKey = options.rpcUrlSecret ?? "RPC_URL";
|
|
34
43
|
const keyKey = options.signerKeySecret ?? "KEEPER_PRIVATE_KEY";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
// URL sources, in precedence order: rpcUrls[] → rpcUrl → env (which may itself
|
|
45
|
+
// hold several comma/whitespace-separated URLs). Failover rotates across them.
|
|
46
|
+
const fromEnv = env?.[rpcKey] ?? "";
|
|
47
|
+
this.rpcUrls = (options.rpcUrls && options.rpcUrls.length
|
|
48
|
+
? options.rpcUrls
|
|
49
|
+
: options.rpcUrl
|
|
50
|
+
? [options.rpcUrl]
|
|
51
|
+
: fromEnv.split(/[\s,]+/))
|
|
52
|
+
.map((u) => u.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
if (!this.rpcUrls.length) {
|
|
55
|
+
throw new Error(`Chain: no RPC URL — set the ${rpcKey} secret (set_secret ${rpcKey}=<https rpc>) or pass rpcUrl/rpcUrls.`);
|
|
38
56
|
}
|
|
57
|
+
this.rpcUrl = this.rpcUrls[0];
|
|
39
58
|
this.privateKey = options.privateKey ?? env?.[keyKey];
|
|
40
59
|
this.address = this.privateKey ? privateKeyToAddress(this.privateKey) : null;
|
|
41
60
|
this._chainId = options.chainId;
|
|
@@ -47,19 +66,46 @@ export class Chain {
|
|
|
47
66
|
};
|
|
48
67
|
}
|
|
49
68
|
// --- raw JSON-RPC ----------------------------------------------------------
|
|
50
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Raw JSON-RPC passthrough. Throws on RPC errors with the node's message.
|
|
71
|
+
* With multiple `rpcUrls`, a TRANSPORT failure (network error, 5xx, 429) on one
|
|
72
|
+
* endpoint rotates to the next; a JSON-level `error` (a deterministic chain
|
|
73
|
+
* response, e.g. revert/nonce) is returned as-is — retrying elsewhere wouldn't
|
|
74
|
+
* change it. The start endpoint rotates per call to spread load.
|
|
75
|
+
*/
|
|
51
76
|
async rpc(method, params = []) {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
77
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
|
78
|
+
const n = this.rpcUrls.length;
|
|
79
|
+
const start = this.rpcCursor++ % n;
|
|
80
|
+
let lastErr;
|
|
81
|
+
for (let i = 0; i < n; i++) {
|
|
82
|
+
const url = this.rpcUrls[(start + i) % n];
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(url, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "content-type": "application/json" },
|
|
87
|
+
body,
|
|
88
|
+
});
|
|
89
|
+
// Retry-worthy HTTP statuses → try the next endpoint.
|
|
90
|
+
if (res.status >= 500 || res.status === 429) {
|
|
91
|
+
lastErr = new Error(`RPC ${method} HTTP ${res.status}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!res.ok)
|
|
95
|
+
throw new Error(`RPC ${method} HTTP ${res.status}`);
|
|
96
|
+
const json = (await res.json());
|
|
97
|
+
if (json.error)
|
|
98
|
+
throw new Error(`RPC ${method} error ${json.error.code}: ${json.error.message}`);
|
|
99
|
+
return json.result;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
// A 4xx (other than 429) or chain-level error is deterministic — surface it.
|
|
103
|
+
if (err instanceof Error && /HTTP 4|RPC .* error /.test(err.message))
|
|
104
|
+
throw err;
|
|
105
|
+
lastErr = err; // network/transport error → failover to the next endpoint
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw lastErr ?? new Error(`RPC ${method} failed on all ${n} endpoint(s)`);
|
|
63
109
|
}
|
|
64
110
|
async getChainId() {
|
|
65
111
|
if (this._chainId === undefined) {
|
|
@@ -104,6 +150,15 @@ export class Chain {
|
|
|
104
150
|
const raw = await this.rpc("eth_getLogs", [filter]);
|
|
105
151
|
return raw.map((l) => decodeLog(opts.event, l));
|
|
106
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Batch many contract reads into ONE eth_call via Multicall3 (aggregate3).
|
|
155
|
+
* Results are positionally aligned with `calls`; a reverting call yields null
|
|
156
|
+
* when allowFailure (default true) or throws otherwise. All calls observe the
|
|
157
|
+
* same block, so the batch is atomic/consistent.
|
|
158
|
+
*/
|
|
159
|
+
multicall(calls, opts) {
|
|
160
|
+
return multicallImpl(this, calls, opts);
|
|
161
|
+
}
|
|
107
162
|
// --- writes ----------------------------------------------------------------
|
|
108
163
|
/** Low-level: 0x calldata for a function call (no broadcast). */
|
|
109
164
|
encodeFunction(abi, functionName, args = []) {
|
|
@@ -149,6 +204,54 @@ export class Chain {
|
|
|
149
204
|
throw err;
|
|
150
205
|
}
|
|
151
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Deploy a contract: build a creation tx (to:null, data = bytecode ++ encoded
|
|
209
|
+
* constructor args), sign + broadcast on the same nonce/fee/gas path as send(),
|
|
210
|
+
* wait for the receipt, and return the deployed address. Note: the default
|
|
211
|
+
* fallbackGas (500k) is low for real contracts — pass `gas` for large ones.
|
|
212
|
+
*/
|
|
213
|
+
async deploy(opts) {
|
|
214
|
+
if (!this.privateKey || !this.address)
|
|
215
|
+
throw this.noKey();
|
|
216
|
+
const ctor = findConstructor(opts.abi);
|
|
217
|
+
const args = opts.args ?? [];
|
|
218
|
+
if (args.length && !ctor) {
|
|
219
|
+
throw new Error("deploy: constructor args provided but the ABI has no constructor entry.");
|
|
220
|
+
}
|
|
221
|
+
const encodedArgs = args.length ? encodeParameters(ctor?.inputs ?? [], args) : new Uint8Array(0);
|
|
222
|
+
const data = bytesToHex(concatBytes(hexToBytes(opts.bytecode), encodedArgs));
|
|
223
|
+
const value = opts.value ?? 0n;
|
|
224
|
+
const [chainId, nonce, fees] = await Promise.all([
|
|
225
|
+
this.getChainId(),
|
|
226
|
+
opts.nonce !== undefined ? Promise.resolve(opts.nonce) : this.nextNonce(this.address),
|
|
227
|
+
this.feeData(),
|
|
228
|
+
]);
|
|
229
|
+
const gas = opts.gas ?? (await this.estimateGas({ to: null, from: this.address, data, value }));
|
|
230
|
+
const tx = {
|
|
231
|
+
chainId: BigInt(chainId),
|
|
232
|
+
nonce,
|
|
233
|
+
maxPriorityFeePerGas: opts.maxPriorityFeePerGas ?? fees.maxPriorityFeePerGas,
|
|
234
|
+
maxFeePerGas: opts.maxFeePerGas ?? fees.maxFeePerGas,
|
|
235
|
+
gas,
|
|
236
|
+
to: null,
|
|
237
|
+
value,
|
|
238
|
+
data,
|
|
239
|
+
};
|
|
240
|
+
const raw = signTransaction(tx, this.privateKey);
|
|
241
|
+
let hash;
|
|
242
|
+
try {
|
|
243
|
+
hash = await this.rpc("eth_sendRawTransaction", [raw]);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
await this.explainSendFailure(err, value, gas, tx.maxFeePerGas);
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
const receipt = await this.waitForReceipt(hash, { timeoutMs: opts.timeoutMs, pollMs: opts.pollMs });
|
|
250
|
+
if (receipt.status === "reverted" || !receipt.contractAddress) {
|
|
251
|
+
throw new Error(`deploy: creation tx ${hash} ${receipt.status === "reverted" ? "reverted" : "produced no contract address"}.`);
|
|
252
|
+
}
|
|
253
|
+
return { address: receipt.contractAddress, hash, receipt };
|
|
254
|
+
}
|
|
152
255
|
/**
|
|
153
256
|
* Optimistic reliable send (docs/WALLET-SPEC.md "optimistic, assigned at
|
|
154
257
|
* broadcast"). Resolves the nonce at broadcast (never pre-allocated), retries on a
|
|
@@ -243,12 +346,14 @@ export class Chain {
|
|
|
243
346
|
}
|
|
244
347
|
return { maxFeePerGas: baseFee * 2n + tip, maxPriorityFeePerGas: tip };
|
|
245
348
|
}
|
|
246
|
-
/** estimateGas with headroom and a safe fallback when the node reverts the estimate.
|
|
349
|
+
/** estimateGas with headroom and a safe fallback when the node reverts the estimate.
|
|
350
|
+
* `to: null` estimates a contract-creation tx (the `to` field is omitted). */
|
|
247
351
|
async estimateGas(tx) {
|
|
248
352
|
try {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
353
|
+
const call = { from: tx.from, data: tx.data, value: toQuantity(tx.value) };
|
|
354
|
+
if (tx.to !== null)
|
|
355
|
+
call.to = tx.to;
|
|
356
|
+
const est = BigInt(await this.rpc("eth_estimateGas", [call]));
|
|
252
357
|
return (est * this.opts.gasMultiplierPct) / 100n;
|
|
253
358
|
}
|
|
254
359
|
catch {
|
package/dist/contracts.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { AbiFunction } from "./abi.js";
|
|
2
2
|
import type { Chain } from "./chain.js";
|
|
3
|
+
import type { MulticallOptions } from "./multicall.js";
|
|
3
4
|
import type { Hex } from "./hex.js";
|
|
4
5
|
export type AddressBook = Record<number, Record<string, string>>;
|
|
5
6
|
export type AbiBook = Record<string, AbiFunction[]>;
|
|
7
|
+
/** A read on this handle to batch through multicall (function + args). */
|
|
8
|
+
export interface HandleMulticallRead {
|
|
9
|
+
functionName: string;
|
|
10
|
+
args?: unknown[];
|
|
11
|
+
allowFailure?: boolean;
|
|
12
|
+
}
|
|
6
13
|
export interface ContractHandle {
|
|
7
14
|
readonly address: Hex;
|
|
8
15
|
readonly abi: AbiFunction[];
|
|
@@ -10,6 +17,8 @@ export interface ContractHandle {
|
|
|
10
17
|
read: Record<string, (...args: unknown[]) => Promise<unknown>>;
|
|
11
18
|
/** Write methods (sign + eth_sendRawTransaction) keyed by function name; returns tx hash. */
|
|
12
19
|
write: Record<string, (...args: unknown[]) => Promise<Hex>>;
|
|
20
|
+
/** Batch several reads on THIS contract into one eth_call (Multicall3). */
|
|
21
|
+
multicallRead(calls: HandleMulticallRead[], opts?: MulticallOptions): Promise<(unknown | null)[]>;
|
|
13
22
|
}
|
|
14
23
|
export interface ContractBinding {
|
|
15
24
|
readonly name: string;
|
|
@@ -21,6 +30,16 @@ export interface ContractBinding {
|
|
|
21
30
|
address?: Hex;
|
|
22
31
|
}): ContractHandle;
|
|
23
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Batch reads across DIFFERENT bound contracts into one eth_call. Each entry
|
|
35
|
+
* names a connected handle + a function on it; results are positionally aligned.
|
|
36
|
+
*/
|
|
37
|
+
export declare function multicallContracts(chain: Chain, calls: Array<{
|
|
38
|
+
handle: ContractHandle;
|
|
39
|
+
functionName: string;
|
|
40
|
+
args?: unknown[];
|
|
41
|
+
allowFailure?: boolean;
|
|
42
|
+
}>, opts?: MulticallOptions): Promise<(unknown | null)[]>;
|
|
24
43
|
/**
|
|
25
44
|
* Build name-addressable contract bindings from generated address/ABI books.
|
|
26
45
|
* The generated bindings module calls this and exports the result as `contracts`.
|
package/dist/contracts.js
CHANGED
|
@@ -27,7 +27,21 @@ function buildHandle(chain, address, abi) {
|
|
|
27
27
|
read[name] = read[name] ?? ((...args) => chain.readContract({ address, abi, functionName: name, args }));
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
const multicallRead = (calls, opts) => chain.multicall(calls.map((c) => ({ address, abi, functionName: c.functionName, args: c.args, allowFailure: c.allowFailure })), opts);
|
|
31
|
+
return { address, abi, read, write, multicallRead };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Batch reads across DIFFERENT bound contracts into one eth_call. Each entry
|
|
35
|
+
* names a connected handle + a function on it; results are positionally aligned.
|
|
36
|
+
*/
|
|
37
|
+
export function multicallContracts(chain, calls, opts) {
|
|
38
|
+
return chain.multicall(calls.map((c) => ({
|
|
39
|
+
address: c.handle.address,
|
|
40
|
+
abi: c.handle.abi,
|
|
41
|
+
functionName: c.functionName,
|
|
42
|
+
args: c.args,
|
|
43
|
+
allowFailure: c.allowFailure,
|
|
44
|
+
})), opts);
|
|
31
45
|
}
|
|
32
46
|
/**
|
|
33
47
|
* Build name-addressable contract bindings from generated address/ABI books.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
export { Chain, isNonceCollision } from "./chain.js";
|
|
2
|
-
export type { ChainOptions, SendOptions, SendReliableOptions, ReadOptions, GetLogsOptions, Receipt, } from "./chain.js";
|
|
2
|
+
export type { ChainOptions, SendOptions, SendReliableOptions, ReadOptions, GetLogsOptions, Receipt, DeployOptions, } from "./chain.js";
|
|
3
|
+
export { multicall, MULTICALL3_ADDRESS } from "./multicall.js";
|
|
4
|
+
export type { MulticallCall, MulticallOptions } from "./multicall.js";
|
|
5
|
+
export { watchLogs } from "./watch.js";
|
|
6
|
+
export type { WatchLogsOptions, WatchLogsResult } from "./watch.js";
|
|
3
7
|
export { Relayer } from "./relayer.js";
|
|
8
|
+
export { Queue, queue, queueEnvKey } from "./queue.js";
|
|
4
9
|
export type { RelayerOptions } from "./relayer.js";
|
|
10
|
+
export { Indexer, indexer, indexerEnvKey, GraphQLError } from "./indexer.js";
|
|
11
|
+
export type { IndexerOptions } from "./indexer.js";
|
|
12
|
+
export { Telegram } from "./telegram.js";
|
|
13
|
+
export type { TelegramOptions, BotMessage, SendMessageOptions } from "./telegram.js";
|
|
5
14
|
export { Store, STORE_TABLE } from "./store.js";
|
|
6
15
|
export type { D1Like } from "./store.js";
|
|
7
16
|
export { log } from "./log.js";
|
|
8
17
|
export { retry } from "./retry.js";
|
|
9
18
|
export type { RetryOptions } from "./retry.js";
|
|
19
|
+
export { requireSecret, requireSecrets, getSecret } from "./secret.js";
|
|
10
20
|
export { encodeFunctionData, encodeParameters, decodeParameters, decodeFunctionResult, functionSelector, functionSignature, functionFromSignature, parseSignature, findFunction, keccak256, } from "./abi.js";
|
|
11
21
|
export type { AbiFunction, AbiParameter, ParsedSignature } from "./abi.js";
|
|
12
22
|
export { signTransaction, transactionHashToSign, transactionHash, privateKeyToAddress, toChecksumAddress, } from "./tx.js";
|
|
@@ -15,5 +25,5 @@ export { eventTopic, encodeFilterTopics, encodeIndexedTopic, decodeLog, parseEve
|
|
|
15
25
|
export type { AbiEvent, RawLog, DecodedLog } from "./events.js";
|
|
16
26
|
export { bytesToHex, hexToBytes, concatBytes, toBigInt, } from "./hex.js";
|
|
17
27
|
export type { Hex, Bytes } from "./hex.js";
|
|
18
|
-
export { bindContracts } from "./contracts.js";
|
|
19
|
-
export type { ContractBinding, ContractHandle, AddressBook, AbiBook } from "./contracts.js";
|
|
28
|
+
export { bindContracts, multicallContracts } from "./contracts.js";
|
|
29
|
+
export type { ContractBinding, ContractHandle, HandleMulticallRead, AddressBook, AbiBook, } from "./contracts.js";
|
package/dist/index.js
CHANGED
|
@@ -3,17 +3,28 @@
|
|
|
3
3
|
// tree-shakeable exports; keep the core small and auditable (it may hold keys).
|
|
4
4
|
// Layer 1 — Chain: sign + send + read with zero hand-rolled crypto/ABI.
|
|
5
5
|
export { Chain, isNonceCollision } from "./chain.js";
|
|
6
|
+
// Multicall — batch reads into one eth_call (Multicall3 aggregate3).
|
|
7
|
+
export { multicall, MULTICALL3_ADDRESS } from "./multicall.js";
|
|
8
|
+
// watchLogs — resumable, cursor-persisted event catch-up for keepers.
|
|
9
|
+
export { watchLogs } from "./watch.js";
|
|
6
10
|
// Relayer — managed signing for bots (custodied RELAYER_PRIVATE_KEY + optional
|
|
7
11
|
// Convex-serialized nonces). A Chain that defaults to the relayer key.
|
|
8
12
|
export { Relayer } from "./relayer.js";
|
|
13
|
+
export { Queue, queue, queueEnvKey } from "./queue.js";
|
|
14
|
+
// Indexer — typed GraphQL client for a project's Goldsky subgraph (reads the
|
|
15
|
+
// stable `live`-alias endpoint injected as INDEXER_<NAME>_URL).
|
|
16
|
+
export { Indexer, indexer, indexerEnvKey, GraphQLError } from "./indexer.js";
|
|
17
|
+
// Telegram — webhook verification + reply plumbing for bots.
|
|
18
|
+
export { Telegram } from "./telegram.js";
|
|
9
19
|
// Layer 3 — State + utilities.
|
|
10
20
|
export { Store, STORE_TABLE } from "./store.js";
|
|
11
21
|
export { log } from "./log.js";
|
|
12
22
|
export { retry } from "./retry.js";
|
|
23
|
+
export { requireSecret, requireSecrets, getSecret } from "./secret.js";
|
|
13
24
|
// Low-level escape hatches — ABI coding, signing, RLP, hex. Power users only.
|
|
14
25
|
export { encodeFunctionData, encodeParameters, decodeParameters, decodeFunctionResult, functionSelector, functionSignature, functionFromSignature, parseSignature, findFunction, keccak256, } from "./abi.js";
|
|
15
26
|
export { signTransaction, transactionHashToSign, transactionHash, privateKeyToAddress, toChecksumAddress, } from "./tx.js";
|
|
16
27
|
export { eventTopic, encodeFilterTopics, encodeIndexedTopic, decodeLog, parseEventSignature, } from "./events.js";
|
|
17
28
|
export { bytesToHex, hexToBytes, concatBytes, toBigInt, } from "./hex.js";
|
|
18
29
|
// Layer 2 — generated contract bindings live in "@livo/runtime/contracts".
|
|
19
|
-
export { bindContracts } from "./contracts.js";
|
|
30
|
+
export { bindContracts, multicallContracts } from "./contracts.js";
|