@livo-build/runtime 0.1.0 → 0.2.1

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/dist/chain.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type AbiFunction } from "./abi.js";
2
2
  import { type AbiEvent, type DecodedLog } from "./events.js";
3
3
  import { type Eip1559Tx } from "./tx.js";
4
4
  import { type Hex } from "./hex.js";
5
+ export declare function isNonceCollision(err: unknown): boolean;
5
6
  export interface ChainOptions {
6
7
  /** env key holding the JSON-RPC URL. Default "RPC_URL". */
7
8
  rpcUrlSecret?: string;
@@ -33,6 +34,21 @@ export interface SendOptions {
33
34
  maxFeePerGas?: bigint;
34
35
  maxPriorityFeePerGas?: bigint;
35
36
  }
37
+ export interface SendReliableOptions extends SendOptions {
38
+ /** Max broadcast attempts on a nonce collision (re-reads pending each retry). Default 5. */
39
+ maxAttempts?: number;
40
+ /** Delay between collision retries, ms. Default 250 (0 in tests). */
41
+ retryDelayMs?: number;
42
+ /** If set, wait this long for the tx to mine; if it doesn't, rebroadcast at the
43
+ * SAME nonce with a bumped fee (replacement). Default: don't wait, return the hash. */
44
+ confirmMs?: number;
45
+ /** Receipt poll interval while waiting, ms. Default 2000. */
46
+ pollMs?: number;
47
+ /** Max fee-bump replacements when confirmMs elapses with no receipt. Default 3. */
48
+ maxReplacements?: number;
49
+ /** Fee bump per replacement, percent (must clear the node's ~10% min). Default 15. */
50
+ bumpPct?: bigint;
51
+ }
36
52
  export interface ReadOptions {
37
53
  address: Hex;
38
54
  abi: AbiFunction[];
@@ -93,7 +109,24 @@ export declare class Chain {
93
109
  * Returns the transaction hash. Fails loudly on a funding-less signer.
94
110
  */
95
111
  send(opts: SendOptions): Promise<Hex>;
112
+ /**
113
+ * Optimistic reliable send (docs/WALLET-SPEC.md "optimistic, assigned at
114
+ * broadcast"). Resolves the nonce at broadcast (never pre-allocated), retries on a
115
+ * nonce collision by re-reading the pending count, and — when confirmMs is set —
116
+ * waits for the receipt and rebroadcasts at the SAME nonce with a bumped fee if the
117
+ * tx is stuck. Returns the hash of the (last) broadcast.
118
+ */
119
+ sendReliable(opts: SendReliableOptions): Promise<Hex>;
120
+ /** Poll eth_getTransactionReceipt until mined or the timeout elapses (null). */
121
+ waitReceipt(hash: Hex, timeoutMs: number, pollMs?: number): Promise<Receipt | null>;
96
122
  pendingNonce(address: Hex): Promise<bigint>;
123
+ /**
124
+ * The nonce to use for the next send (when the caller didn't pin one). Defaults
125
+ * to the RPC pending count — fine for a single signer. Subclasses (see Relayer)
126
+ * override this to serialize nonces across concurrent invocations that share one
127
+ * managed signing key, which the per-Worker RPC count alone can't coordinate.
128
+ */
129
+ protected nextNonce(address: Hex): Promise<bigint>;
97
130
  /** Compute maxFee/tip: maxFee = baseFee*2 + tip, tip = configurable default. */
98
131
  feeData(): Promise<{
99
132
  maxFeePerGas: bigint;
package/dist/chain.js CHANGED
@@ -7,6 +7,19 @@ import { decodeLog, encodeFilterTopics, } from "./events.js";
7
7
  import { privateKeyToAddress, signTransaction, transactionHash, } from "./tx.js";
8
8
  import { hexToBytes } from "./hex.js";
9
9
  const GWEI = 1000000000n;
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+ // Classify an RPC error as a nonce collision — another sender took the nonce we
12
+ // used, or our tx is already in the mempool. The optimistic sender treats these as
13
+ // "re-read the pending count and try again" rather than a hard failure.
14
+ export function isNonceCollision(err) {
15
+ const m = String(err?.message ?? err).toLowerCase();
16
+ return (m.includes("nonce too low") ||
17
+ m.includes("nonce has already been used") ||
18
+ m.includes("already known") ||
19
+ m.includes("already imported") ||
20
+ m.includes("known transaction") ||
21
+ m.includes("replacement transaction underpriced"));
22
+ }
10
23
  export class Chain {
11
24
  rpcUrl;
12
25
  privateKey;
@@ -113,7 +126,7 @@ export class Chain {
113
126
  const value = opts.value ?? 0n;
114
127
  const [chainId, nonce, fees] = await Promise.all([
115
128
  this.getChainId(),
116
- opts.nonce !== undefined ? Promise.resolve(opts.nonce) : this.pendingNonce(this.address),
129
+ opts.nonce !== undefined ? Promise.resolve(opts.nonce) : this.nextNonce(this.address),
117
130
  this.feeData(),
118
131
  ]);
119
132
  const gas = opts.gas ?? (await this.estimateGas({ to: opts.address, from: this.address, data, value }));
@@ -136,9 +149,87 @@ export class Chain {
136
149
  throw err;
137
150
  }
138
151
  }
152
+ /**
153
+ * Optimistic reliable send (docs/WALLET-SPEC.md "optimistic, assigned at
154
+ * broadcast"). Resolves the nonce at broadcast (never pre-allocated), retries on a
155
+ * nonce collision by re-reading the pending count, and — when confirmMs is set —
156
+ * waits for the receipt and rebroadcasts at the SAME nonce with a bumped fee if the
157
+ * tx is stuck. Returns the hash of the (last) broadcast.
158
+ */
159
+ async sendReliable(opts) {
160
+ if (!this.privateKey || !this.address)
161
+ throw this.noKey();
162
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? 5);
163
+ const retryDelayMs = opts.retryDelayMs ?? 250;
164
+ const maxReplacements = Math.max(0, opts.maxReplacements ?? 3);
165
+ const pollMs = opts.pollMs ?? 2000;
166
+ const bumpPct = opts.bumpPct ?? 15n;
167
+ const fees = await this.feeData();
168
+ let maxFeePerGas = opts.maxFeePerGas ?? fees.maxFeePerGas;
169
+ let maxPriorityFeePerGas = opts.maxPriorityFeePerGas ?? fees.maxPriorityFeePerGas;
170
+ // Resolve the nonce ONCE, at broadcast — so a stuck tx can be replaced at the
171
+ // same nonce. On a collision we re-read it (someone else advanced the account).
172
+ let nonce = opts.nonce ?? (await this.nextNonce(this.address));
173
+ let hash;
174
+ for (let attempt = 0;; attempt++) {
175
+ try {
176
+ hash = await this.send({ ...opts, nonce, maxFeePerGas, maxPriorityFeePerGas });
177
+ break;
178
+ }
179
+ catch (err) {
180
+ if (isNonceCollision(err) && attempt < maxAttempts - 1) {
181
+ if (retryDelayMs > 0)
182
+ await sleep(retryDelayMs);
183
+ nonce = await this.pendingNonce(this.address);
184
+ continue;
185
+ }
186
+ throw err;
187
+ }
188
+ }
189
+ if (!opts.confirmMs)
190
+ return hash;
191
+ for (let rep = 0;; rep++) {
192
+ const receipt = await this.waitReceipt(hash, opts.confirmMs, pollMs);
193
+ if (receipt || rep >= maxReplacements)
194
+ return hash;
195
+ // Stuck: bump the fee and rebroadcast at the SAME nonce (replacement tx).
196
+ maxFeePerGas = (maxFeePerGas * (100n + bumpPct)) / 100n;
197
+ maxPriorityFeePerGas = (maxPriorityFeePerGas * (100n + bumpPct)) / 100n;
198
+ try {
199
+ hash = await this.send({ ...opts, nonce, maxFeePerGas, maxPriorityFeePerGas });
200
+ }
201
+ catch (err) {
202
+ // "nonce too low" here means the original mined while we waited — done.
203
+ if (isNonceCollision(err))
204
+ return hash;
205
+ throw err;
206
+ }
207
+ }
208
+ }
209
+ /** Poll eth_getTransactionReceipt until mined or the timeout elapses (null). */
210
+ async waitReceipt(hash, timeoutMs, pollMs = 2000) {
211
+ const deadline = Date.now() + timeoutMs;
212
+ for (;;) {
213
+ const r = await this.rpc("eth_getTransactionReceipt", [hash]);
214
+ if (r)
215
+ return r;
216
+ if (Date.now() >= deadline)
217
+ return null;
218
+ await sleep(Math.max(0, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
219
+ }
220
+ }
139
221
  async pendingNonce(address) {
140
222
  return BigInt(await this.rpc("eth_getTransactionCount", [address, "pending"]));
141
223
  }
224
+ /**
225
+ * The nonce to use for the next send (when the caller didn't pin one). Defaults
226
+ * to the RPC pending count — fine for a single signer. Subclasses (see Relayer)
227
+ * override this to serialize nonces across concurrent invocations that share one
228
+ * managed signing key, which the per-Worker RPC count alone can't coordinate.
229
+ */
230
+ nextNonce(address) {
231
+ return this.pendingNonce(address);
232
+ }
142
233
  /** Compute maxFee/tip: maxFee = baseFee*2 + tip, tip = configurable default. */
143
234
  async feeData() {
144
235
  const tip = this.opts.defaultTipWei;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- export { Chain } from "./chain.js";
2
- export type { ChainOptions, SendOptions, ReadOptions, GetLogsOptions, Receipt, } from "./chain.js";
1
+ export { Chain, isNonceCollision } from "./chain.js";
2
+ export type { ChainOptions, SendOptions, SendReliableOptions, ReadOptions, GetLogsOptions, Receipt, } from "./chain.js";
3
+ export { Relayer } from "./relayer.js";
4
+ export type { RelayerOptions } from "./relayer.js";
3
5
  export { Store, STORE_TABLE } from "./store.js";
4
6
  export type { D1Like } from "./store.js";
5
7
  export { log } from "./log.js";
package/dist/index.js CHANGED
@@ -2,7 +2,10 @@
2
2
  // that isn't a contract or a static frontend (keepers, servers, bots). Named,
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
- export { Chain } from "./chain.js";
5
+ export { Chain, isNonceCollision } from "./chain.js";
6
+ // Relayer — managed signing for bots (custodied RELAYER_PRIVATE_KEY + optional
7
+ // Convex-serialized nonces). A Chain that defaults to the relayer key.
8
+ export { Relayer } from "./relayer.js";
6
9
  // Layer 3 — State + utilities.
7
10
  export { Store, STORE_TABLE } from "./store.js";
8
11
  export { log } from "./log.js";
@@ -0,0 +1,20 @@
1
+ import { Chain, type ChainOptions } from "./chain.js";
2
+ import type { Hex } from "./hex.js";
3
+ export interface RelayerOptions extends ChainOptions {
4
+ /** Convex /relayer/nonce endpoint. Default: env RELAYER_NONCE_URL. */
5
+ nonceUrl?: string;
6
+ /** Bearer scoping the nonce allocation to this bot. Default: env RELAYER_NONCE_TOKEN. */
7
+ nonceToken?: string;
8
+ }
9
+ interface MinimalEnv {
10
+ [key: string]: unknown;
11
+ }
12
+ export declare class Relayer extends Chain {
13
+ private readonly nonceUrl?;
14
+ private readonly nonceToken?;
15
+ constructor(env: MinimalEnv | undefined, options?: RelayerOptions);
16
+ /** True when this relayer will serialize nonces through Convex (vs. RPC pending). */
17
+ get serializesNonce(): boolean;
18
+ protected nextNonce(address: Hex): Promise<bigint>;
19
+ }
20
+ export {};
@@ -0,0 +1,54 @@
1
+ // Relayer — the managed signing layer for bots. A bot's relayer wallet is a
2
+ // platform-custodied key (a SEPARATE key from the project's keeper, so a bot and
3
+ // a keeper never collide on the same address's nonce), injected at deploy as the
4
+ // RELAYER_PRIVATE_KEY binding. So a bot just does `new Relayer(env).send(...)` —
5
+ // no key handling, no faucet ritual, no hand-rolled crypto.
6
+ //
7
+ // When the platform also injects RELAYER_NONCE_URL + RELAYER_NONCE_TOKEN, the
8
+ // relayer asks Convex for a *serialized* nonce before each send. Convex mutations
9
+ // are serializable, so two concurrent bot invocations sharing one relayer key get
10
+ // strictly increasing nonces instead of both reading the same RPC pending count
11
+ // and colliding. With the bindings absent (e.g. local dev), it transparently
12
+ // falls back to the RPC pending count — same behavior as a plain Chain.
13
+ import { Chain } from "./chain.js";
14
+ export class Relayer extends Chain {
15
+ nonceUrl;
16
+ nonceToken;
17
+ constructor(env, options = {}) {
18
+ const { nonceUrl, nonceToken, ...chainOpts } = options;
19
+ // The relayer key is the signer by default — NOT the keeper key.
20
+ super(env, { signerKeySecret: chainOpts.signerKeySecret ?? "RELAYER_PRIVATE_KEY", ...chainOpts });
21
+ this.nonceUrl = nonceUrl ?? env?.RELAYER_NONCE_URL;
22
+ this.nonceToken = nonceToken ?? env?.RELAYER_NONCE_TOKEN;
23
+ }
24
+ /** True when this relayer will serialize nonces through Convex (vs. RPC pending). */
25
+ get serializesNonce() {
26
+ return Boolean(this.nonceUrl && this.nonceToken);
27
+ }
28
+ // Ask Convex for the next serialized nonce, passing the RPC pending count so the
29
+ // server-side counter self-heals when the wallet advanced out-of-band. Any
30
+ // failure (no bindings, network, non-200) falls back to the RPC pending count —
31
+ // the relayer never blocks on the coordinator being reachable.
32
+ async nextNonce(address) {
33
+ const observedPending = await this.pendingNonce(address);
34
+ if (!this.nonceUrl || !this.nonceToken)
35
+ return observedPending;
36
+ try {
37
+ const chainId = await this.getChainId();
38
+ const res = await fetch(this.nonceUrl, {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.nonceToken}` },
41
+ body: JSON.stringify({ address, chainId, observedPending: Number(observedPending) }),
42
+ });
43
+ if (res.ok) {
44
+ const j = (await res.json());
45
+ if (j.ok && typeof j.nonce === "number" && j.nonce >= 0)
46
+ return BigInt(j.nonce);
47
+ }
48
+ }
49
+ catch {
50
+ /* fall through to the RPC pending count */
51
+ }
52
+ return observedPending;
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livo-build/runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Livo runtime — chain signing/reads, D1 state, and logging for keepers, servers, and bots.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",