@livo-build/runtime 0.2.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,6 +109,16 @@ 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>;
97
123
  /**
98
124
  * The nonce to use for the next send (when the caller didn't pin one). Defaults
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;
@@ -136,6 +149,75 @@ 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
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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
3
  export { Relayer } from "./relayer.js";
4
4
  export type { RelayerOptions } from "./relayer.js";
5
5
  export { Store, STORE_TABLE } from "./store.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
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
6
  // Relayer — managed signing for bots (custodied RELAYER_PRIVATE_KEY + optional
7
7
  // Convex-serialized nonces). A Chain that defaults to the relayer key.
8
8
  export { Relayer } from "./relayer.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livo-build/runtime",
3
- "version": "0.2.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",