@black-market/sdk 0.6.0 → 0.6.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.
@@ -1,10 +1,133 @@
1
1
  import { decodeFunctionResult, encodeFunctionData, isAddress, isHex, parseAbi, toHex, type Abi, type Address, type Hex } from "viem";
2
- import { LifecyclePlanningError, type LifecycleBlock, type LifecycleLimitContext, type LifecycleLimitSource, type LifecycleLimits, type LifecycleRpcClient, type LifecycleTransaction, type ResolvedLifecycleLimits } from "./types.js";
2
+ import { LifecyclePlanningError, type LifecycleBlock, type LifecycleDiagnosticEvent, type LifecycleDiagnosticListener, type LifecycleDiagnosticStage, type LifecycleLimitContext, type LifecycleLimitSource, type LifecycleLimits, type LifecycleRpcClient, type LifecycleTransaction, type ResolvedLifecycleLimits } from "./types.js";
3
3
  import { lifecycleRegistryAbi } from "./abi.js";
4
4
  import type { ProfileTopologyV1 } from "./schema.js";
5
5
 
6
+ const lifecycleReadConcurrency = 8;
7
+ const diagnosticRpcMethods: Record<string, true> = {
8
+ eth_chainId: true, eth_getBlockByNumber: true, eth_getBlockByHash: true, eth_getCode: true,
9
+ eth_getBalance: true, eth_call: true, eth_estimateGas: true, eth_gasPrice: true,
10
+ eth_simulateV1: true, eth_getTransactionCount: true, eth_getTransactionReceipt: true, eth_getTransactionByHash: true,
11
+ };
12
+
13
+ /** Owns only one invocation's immutable RPC observations, never live checks or writes. */
14
+ class InvocationLifecycleClient implements LifecycleRpcClient {
15
+ private readonly reads = new Map<string, Promise<unknown>>();
16
+ private readonly active = new Set<Promise<void>>();
17
+ private closed = false;
18
+ private readonly concurrency: number;
19
+ private diagnosticSequence = 0;
20
+ constructor(readonly source: LifecycleRpcClient) {
21
+ this.concurrency = source.supportsReadBatching === true ? lifecycleReadConcurrency : 1;
22
+ }
23
+
24
+ async request(args: { method: string; params?: readonly unknown[] }): Promise<unknown> {
25
+ this.assertOpen();
26
+ const result = await this.source.request(args);
27
+ this.assertOpen();
28
+ return result;
29
+ }
30
+
31
+ private assertOpen(): void {
32
+ if (this.closed) throw new LifecyclePlanningError("READ_SCOPE_CLOSED", "Lifecycle read invocation has already settled");
33
+ }
34
+
35
+ async run(method: string, params: readonly unknown[]): Promise<unknown> {
36
+ this.assertOpen();
37
+ const listener = this.source.onDiagnostic;
38
+ const observedMethod = listener === undefined ? undefined : diagnosticRpcMethods[method] === true ? method : "other";
39
+ const queuedAt = listener === undefined ? 0 : performance.now();
40
+ const requestId = listener === undefined ? 0 : ++this.diagnosticSequence;
41
+ if (listener !== undefined) emitLifecycleDiagnostic(listener, { stage: "rpc", phase: "queued", requestId, method: observedMethod });
42
+ while (this.active.size >= this.concurrency) await Promise.race(this.active);
43
+ this.assertOpen();
44
+ const started = listener === undefined ? 0 : performance.now();
45
+ if (listener !== undefined) emitLifecycleDiagnostic(listener, { stage: "rpc", phase: "start", requestId, method: observedMethod, queueMs: started - queuedAt });
46
+ const pending = this.request({ method, params });
47
+ const settled = pending.then(
48
+ () => { this.active.delete(settled); },
49
+ () => { this.active.delete(settled); },
50
+ );
51
+ this.active.add(settled);
52
+ try {
53
+ const result = await pending;
54
+ if (listener !== undefined) emitLifecycleDiagnostic(listener, { stage: "rpc", phase: "success", requestId, method: observedMethod, durationMs: performance.now() - started });
55
+ return result;
56
+ } catch (error) {
57
+ if (listener !== undefined) emitLifecycleDiagnostic(listener, { stage: "rpc", phase: "failure", requestId, method: observedMethod, durationMs: performance.now() - started });
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ read(method: string, params: readonly unknown[], block: LifecycleBlock): Promise<unknown> {
63
+ this.assertOpen();
64
+ // Source identity is this scope's original client. The complete wire call includes
65
+ // caller, value, gas and any other call fields; no selector-only/ABI-name aliasing.
66
+ const key = JSON.stringify([block.number.toString(), block.hash.toLowerCase(), method, params]);
67
+ const existing = this.reads.get(key);
68
+ if (existing !== undefined) {
69
+ const listener = this.source.onDiagnostic;
70
+ if (listener !== undefined) emitLifecycleDiagnostic(listener, { stage: "rpc", phase: "reuse", method: diagnosticRpcMethods[method] === true ? method : "other" });
71
+ return existing;
72
+ }
73
+ const pending = this.run(method, params);
74
+ this.reads.set(key, pending);
75
+ void pending.catch(() => { if (this.reads.get(key) === pending) this.reads.delete(key); });
76
+ return pending;
77
+ }
78
+
79
+ close(): void {
80
+ this.closed = true;
81
+ this.reads.clear();
82
+ }
83
+ }
84
+
85
+ /** Internal callers join the same invocation; no process-wide/source-wide read cache. */
86
+ export async function withLifecycleReadClient<T>(source: LifecycleRpcClient, work: (client: LifecycleRpcClient) => Promise<T>): Promise<T> {
87
+ if (source instanceof InvocationLifecycleClient) return work(source);
88
+ const client = new InvocationLifecycleClient(source);
89
+ try { return await work(client); }
90
+ finally { client.close(); }
91
+ }
92
+
93
+ export function lifecycleSourceClient(client: LifecycleRpcClient): LifecycleRpcClient {
94
+ return client instanceof InvocationLifecycleClient ? client.source : client;
95
+ }
96
+
97
+ function emitLifecycleDiagnostic(listener: LifecycleDiagnosticListener, event: LifecycleDiagnosticEvent): void {
98
+ try {
99
+ const pending = listener(event);
100
+ if (pending !== undefined) void pending.catch(() => {});
101
+ } catch { /* Observers cannot change financial behavior. */ }
102
+ }
103
+
104
+ /** No clock, event allocation or extra promise when diagnostics are disabled. */
105
+ export function lifecycleStage<T>(client: LifecycleRpcClient, stage: LifecycleDiagnosticStage, work: () => Promise<T>): Promise<T> {
106
+ const listener = lifecycleSourceClient(client).onDiagnostic;
107
+ return listener === undefined ? work() : observedLifecycleStage(listener, stage, work);
108
+ }
109
+
110
+ async function observedLifecycleStage<T>(listener: LifecycleDiagnosticListener, stage: LifecycleDiagnosticStage, work: () => Promise<T>): Promise<T> {
111
+ const started = performance.now();
112
+ emitLifecycleDiagnostic(listener, { stage, phase: "start" });
113
+ try {
114
+ const result = await work();
115
+ emitLifecycleDiagnostic(listener, { stage, phase: "success", durationMs: performance.now() - started });
116
+ return result;
117
+ } catch (error) {
118
+ emitLifecycleDiagnostic(listener, { stage, phase: "failure", durationMs: performance.now() - started });
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ /** Only explicitly pinned fixed-state reads may participate in invocation reuse. */
124
+ export async function lifecyclePinnedRpc(client: LifecycleRpcClient, method: "eth_call" | "eth_getCode" | "eth_getBalance", params: readonly unknown[], block: LifecycleBlock): Promise<unknown> {
125
+ if (params.length !== 2 || params[1] !== toHex(block.number)) throw new LifecyclePlanningError("INVALID_READ_CONTEXT", "Reusable lifecycle reads require the exact pinned block and no state overrides");
126
+ return client instanceof InvocationLifecycleClient ? client.read(method, params, block) : lifecycleRpc(client, method, params);
127
+ }
128
+
6
129
  export async function lifecycleRpc(client: LifecycleRpcClient, method: string, params: readonly unknown[] = []): Promise<unknown> {
7
- return client.request({ method, params });
130
+ return client instanceof InvocationLifecycleClient ? client.run(method, params) : client.request({ method, params });
8
131
  }
9
132
  export function rpcObject(value: unknown, label: string): Record<string, unknown> {
10
133
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", `${label} must be an object`);
@@ -32,13 +155,20 @@ export async function readLifecycleBlock(client: LifecycleRpcClient, tag: Hex |
32
155
  };
33
156
  }
34
157
  export async function assertLifecycleBlock(client: LifecycleRpcClient, block: LifecycleBlock, expectedChainId?: bigint): Promise<void> {
35
- if (expectedChainId !== undefined && rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID") !== expectedChainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Source RPC chain changed during the pinned operation");
36
- const current = await readLifecycleBlock(client, toHex(block.number));
158
+ const [chainId, current] = await Promise.all([
159
+ expectedChainId === undefined ? undefined : lifecycleRpc(client, "eth_chainId"),
160
+ readLifecycleBlock(client, toHex(block.number)),
161
+ ]);
162
+ if (expectedChainId !== undefined && rpcQuantity(chainId, "chain ID") !== expectedChainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Source RPC chain changed during the pinned operation");
37
163
  if (current.hash.toLowerCase() !== block.hash.toLowerCase()) throw new LifecyclePlanningError("STATE_REORGED", "Pinned launch state changed during the operation; read canonical progress again");
38
164
  }
39
165
  export async function readLifecycleContract<T>(client: LifecycleRpcClient, to: Address, abi: Abi, functionName: string, args: readonly unknown[], block: LifecycleBlock, from?: Address): Promise<T> {
40
166
  const data = encodeFunctionData({ abi, functionName, args });
41
- const result = rpcHex(await lifecycleRpc(client, "eth_call", [{ to, from, data }, toHex(block.number)]), functionName);
167
+ const request = [{ to, from, data }, toHex(block.number)];
168
+ const getter = abi.some((item) => item.type === "function" && item.name === functionName && (item.stateMutability === "view" || item.stateMutability === "pure"));
169
+ const result = rpcHex(await (getter && functionName !== "readLaunchProgress"
170
+ ? lifecyclePinnedRpc(client, "eth_call", request, block)
171
+ : lifecycleRpc(client, "eth_call", request)), functionName);
42
172
  // ABI decoding validates scalar widths and tuple layout; T names that exact frozen ABI result.
43
173
  const decoded = decodeFunctionResult({ abi, functionName, data: result }) as T;
44
174
  return decoded;
@@ -61,7 +191,7 @@ const maxUint64 = (1n << 64n) - 1n;
61
191
  /** A poster quote is an envelope budget, never an execution/compute proof. */
62
192
  export async function readNitroPosterGas(transaction: LifecycleTransaction, context: LifecycleLimitContext): Promise<{ posterGas: bigint; baseFee: bigint; l1BaseFeeEstimate: bigint }> {
63
193
  const data = encodeFunctionData({ abi: nitroNodeInterfaceAbi, functionName: "gasEstimateL1Component", args: [transaction.to, false, transaction.data] });
64
- const result = rpcHex(await lifecycleRpc(context.client, "eth_call", [{ from: transaction.from, to: nitroNodeInterface, data, value: toHex(transaction.value) }, toHex(context.block.number)]), "Nitro poster estimate");
194
+ const result = rpcHex(await lifecyclePinnedRpc(context.client, "eth_call", [{ from: transaction.from, to: nitroNodeInterface, data, value: toHex(transaction.value) }, toHex(context.block.number)], context.block), "Nitro poster estimate");
65
195
  if (result.length !== 194) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Nitro poster estimate must contain exactly three ABI words");
66
196
  const [posterGas, baseFee, l1BaseFeeEstimate] = decodeFunctionResult({ abi: nitroNodeInterfaceAbi, functionName: "gasEstimateL1Component", data: result });
67
197
  if (posterGas < 0n || posterGas > maxUint64 || baseFee <= 0n || l1BaseFeeEstimate < 0n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Nitro poster estimate has invalid gas or fee values");
@@ -70,14 +200,15 @@ export async function readNitroPosterGas(transaction: LifecycleTransaction, cont
70
200
 
71
201
  async function readNitroScalar(context: LifecycleLimitContext, to: Address, abi: Abi, functionName: string): Promise<bigint> {
72
202
  const data = encodeFunctionData({ abi, functionName });
73
- const result = rpcHex(await lifecycleRpc(context.client, "eth_call", [{ to, data }, toHex(context.block.number)]), functionName);
203
+ const result = rpcHex(await lifecyclePinnedRpc(context.client, "eth_call", [{ to, data }, toHex(context.block.number)], context.block), functionName);
74
204
  if (result.length !== 66) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", `${functionName} must return exactly one ABI word`);
75
205
  return decodeFunctionResult({ abi, functionName, data: result }) as bigint;
76
206
  }
77
207
 
78
208
  export async function resolveLifecycleLimits(context: LifecycleLimitContext, source?: LifecycleLimitSource): Promise<{ limits: ResolvedLifecycleLimits; configuration: LifecycleLimits }> {
79
209
  let configuration: LifecycleLimits;
80
- try { configuration = source === undefined ? {} : typeof source === "function" ? await source(context) : source; }
210
+ const policyContext = typeof source === "function" && context.client instanceof InvocationLifecycleClient ? { ...context, client: context.client.source } : context;
211
+ try { configuration = source === undefined ? {} : typeof source === "function" ? await source(policyContext) : source; }
81
212
  catch (failure) { throw new LifecyclePlanningError("LIMIT_SOURCE_FAILED", `Supplied execution policy could not be resolved: ${failure instanceof Error ? failure.message : String(failure)}`); }
82
213
  if (configuration === null || typeof configuration !== "object" || Array.isArray(configuration)) throw new LifecyclePlanningError("INVALID_LIMITS", "Supplied execution policy must be an object");
83
214
  const headroomBps = configuration.headroomBps === undefined ? 1500 : configuration.headroomBps;
@@ -98,12 +229,16 @@ export async function resolveLifecycleLimits(context: LifecycleLimitContext, sou
98
229
  let arbOSVersion: bigint | undefined; let maxTxComputeGas: bigint | undefined; let maxBlockComputeGas: bigint | undefined;
99
230
  if (protocol === "nitro") {
100
231
  try {
101
- const rawVersion = await readNitroScalar(context, nitroArbSys, nitroArbSysAbi, "arbOSVersion");
232
+ const [rawVersion, txComputeGas, blockComputeGas] = await Promise.all([
233
+ readNitroScalar(context, nitroArbSys, nitroArbSysAbi, "arbOSVersion"),
234
+ readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxTxGasLimit"),
235
+ readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxBlockGasLimit"),
236
+ ]);
102
237
  // ArbSys alone adds 55 to the logical Nitro ArbOS version.
103
238
  if (typeof rawVersion !== "bigint" || rawVersion < 105n) throw new Error("ArbOS version 50 or newer is required (ArbSys raw version >= 105)");
104
239
  arbOSVersion = rawVersion - 55n;
105
- maxTxComputeGas = await readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxTxGasLimit");
106
- maxBlockComputeGas = await readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxBlockGasLimit");
240
+ maxTxComputeGas = txComputeGas;
241
+ maxBlockComputeGas = blockComputeGas;
107
242
  if ([maxTxComputeGas, maxBlockComputeGas].some((limit) => typeof limit !== "bigint" || limit <= 0n || limit > maxUint64)) throw new Error("Nitro compute getters must be positive uint64 values");
108
243
  } catch (failure) { throw new LifecyclePlanningError("NITRO_LIMITS_UNAVAILABLE", `Pinned Nitro compute limits could not be established: ${failure instanceof Error ? failure.message : String(failure)}; header gasLimit is not a fallback`); }
109
244
  if (configuration.estimateDataFee !== undefined) throw new LifecyclePlanningError("INVALID_LIMITS", "Nitro poster fees are already included in gas; an external data-fee estimator would double-charge them");
@@ -1,7 +1,7 @@
1
1
  import { decodeEventLog, decodeFunctionResult, encodeFunctionData, encodeFunctionResult, toHex, type Hex } from "viem";
2
2
  import { launchLifecycleAbi, lifecycleErc20Abi } from "./abi.js";
3
3
  import { LifecyclePhase, type LaunchProgressV1, type LaunchReceiptV1 } from "./schema.js";
4
- import { assertLifecycleBlock, lifecycleRpc, nitroArbSys, nitroArbSysAbi, nitroGasInfo, nitroGasInfoAbi, readLifecycleBlock, readNitroPosterGas, resolveLifecycleLimits, rpcHex, rpcObject, rpcQuantity } from "./rpc.js";
4
+ import { assertLifecycleBlock, lifecyclePinnedRpc, lifecycleRpc, lifecycleSourceClient, lifecycleStage, nitroArbSys, nitroArbSysAbi, nitroGasInfo, nitroGasInfoAbi, readLifecycleBlock, readNitroPosterGas, resolveLifecycleLimits, rpcHex, rpcObject, rpcQuantity } from "./rpc.js";
5
5
  import { LifecyclePlanningError, type ControlledLifecycleFork, type LifecycleBlock, type LifecyclePostcondition, type LifecycleRpcClient, type LifecycleSimulation, type LifecycleSimulationStep, type LifecycleTransaction, type PlannedLaunch, type ResolvedLifecycleLimits, type SimulateLaunchPlanOptions } from "./types.js";
6
6
 
7
7
  function conditionRequest(condition: LifecyclePostcondition, planned: PlannedLaunch): { to: Hex; data: Hex } {
@@ -234,27 +234,36 @@ async function simulateForkPass(fork: ControlledLifecycleFork, planned: PlannedL
234
234
 
235
235
  export async function simulateLaunchTransactions(options: SimulateLaunchPlanOptions, transactions: readonly LifecycleTransaction[], pinnedBlock?: LifecycleBlock): Promise<LifecycleSimulation> {
236
236
  const { client, planned } = options;
237
- if (options.fork?.client === client) throw new LifecyclePlanningError("UNSAFE_FORK", "Source RPC must never receive controlled simulation transactions/snapshots/resets");
237
+ const sourceClient = lifecycleSourceClient(client);
238
+ if (options.fork !== undefined && lifecycleSourceClient(options.fork.client) === sourceClient) throw new LifecyclePlanningError("UNSAFE_FORK", "Source RPC must never receive controlled simulation transactions/snapshots/resets");
238
239
  const block = pinnedBlock ?? await readLifecycleBlock(client);
239
240
  const context = { client, account: planned.account, orchestrator: planned.plan.orchestrator, block, chainId: planned.chainId };
240
- const { limits, configuration } = await resolveLifecycleLimits(context, options.limits === undefined ? planned.limits : options.limits);
241
+ const [{ limits, configuration }, chainId, accountCodeValue, gasPriceValue] = await lifecycleStage(client, "simulation.context", () => Promise.all([
242
+ resolveLifecycleLimits(context, options.limits === undefined ? planned.limits : options.limits),
243
+ lifecycleRpc(client, "eth_chainId"),
244
+ lifecyclePinnedRpc(client, "eth_getCode", [planned.account, toHex(block.number)], block),
245
+ transactions.length === 0 ? undefined : lifecycleRpc(client, "eth_gasPrice"),
246
+ ]));
241
247
  const base = { confidence: "stateful" as const, executionProof: "failed" as const, protocolFit: "unknown" as const, transportPreflight: "not-requested" as const, blockNumber: block.number, blockHash: block.hash, account: planned.account, chainId: planned.chainId, limits };
242
- if (rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID") !== planned.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain changed before sequential simulation");
243
- const accountCode = rpcHex(await lifecycleRpc(client, "eth_getCode", [planned.account, toHex(block.number)]), "account code");
248
+ if (rpcQuantity(chainId, "chain ID") !== planned.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain changed before sequential simulation");
249
+ const accountCode = rpcHex(accountCodeValue, "account code");
244
250
  if (accountCode !== "0x") return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: "Direct EOA transaction simulation cannot prove smart-account signature/execution behavior" };
245
251
  for (const transaction of transactions) {
246
252
  if (transaction.from.toLowerCase() !== planned.account.toLowerCase() || BigInt(transaction.chainId) !== planned.chainId) throw new LifecyclePlanningError("ACCOUNT_MISMATCH", "Simulation transaction differs from the committed chain/account");
247
253
  if (limits.maxCalldataBytes !== undefined && (transaction.data.length - 2) / 2 > limits.maxCalldataBytes) return { ...base, executionProof: "unavailable", protocolFit: "failed", backend: "unavailable", admitted: false, steps: [], failedTransactionId: transaction.id, reason: "Exact calldata exceeds the supplied chain/account/RPC byte cap" };
248
254
  }
249
255
  if (transactions.length === 0) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, executionProof: "proved", protocolFit: "proved", backend: "unavailable", admitted: true, steps: [] }; }
250
- const gasPrice = rpcQuantity(await lifecycleRpc(client, "eth_gasPrice"), "gas price");
256
+ const gasPrice = rpcQuantity(gasPriceValue, "gas price");
251
257
  if (gasPrice >= 1n << 256n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Gas price must be a uint256 quantity");
252
258
  const buffer = (gas: bigint) => (gas * BigInt(10000 + limits.headroomBps) + 9999n) / 10000n;
253
259
  const posterGas: bigint[] = [];
254
260
  if (limits.protocol === "nitro") {
255
261
  try {
256
- await proveNitroBackend(client, planned, block, limits, gasPrice);
257
- for (const transaction of transactions) posterGas.push(buffer((await readNitroPosterGas(transaction, context)).posterGas));
262
+ const [, estimates] = await Promise.all([
263
+ lifecycleStage(client, "simulation.nitro", () => proveNitroBackend(client, planned, block, limits, gasPrice)),
264
+ lifecycleStage(client, "simulation.poster", () => Promise.all(transactions.map(async (transaction) => buffer((await readNitroPosterGas(transaction, context)).posterGas)))),
265
+ ]);
266
+ posterGas.push(...estimates);
258
267
  } catch (failure) {
259
268
  await assertLifecycleBlock(client, block, planned.chainId);
260
269
  return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: `Native Nitro compute/poster proof unavailable: ${failure instanceof Error ? failure.message : String(failure)}. Generic Anvil/Hardhat forks cannot prove ArbOS metering.` };
@@ -273,7 +282,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
273
282
  let backend: "eth_simulateV1" | "controlled-fork" = "eth_simulateV1";
274
283
  let measured: LifecycleSimulationStep[];
275
284
  // Permissive cap measurement is not proof; only the exact-gas validated replay admits a plan.
276
- try { measured = await simulateRpcPass(client, planned, block, transactions, capGas, limits, gasPrice, false); }
285
+ try { measured = await lifecycleStage(client, "simulation.measure", () => simulateRpcPass(client, planned, block, transactions, capGas, limits, gasPrice, false)); }
277
286
  catch (failure) {
278
287
  if (options.fork === undefined || limits.protocol === "nitro") {
279
288
  await assertLifecycleBlock(client, block, planned.chainId);
@@ -300,7 +309,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
300
309
  let verified: LifecycleSimulationStep[];
301
310
  let validatedReplayError: string | undefined;
302
311
  try {
303
- if (backend === "eth_simulateV1") verified = await simulateRpcPass(client, planned, block, transactions, gasLimits, limits, gasPrice, true);
312
+ if (backend === "eth_simulateV1") verified = await lifecycleStage(client, "simulation.replay", () => simulateRpcPass(client, planned, block, transactions, gasLimits, limits, gasPrice, true));
304
313
  else {
305
314
  if (options.fork === undefined) throw new LifecyclePlanningError("INVALID_SIMULATION", "Controlled fork is missing for the exact-gas replay");
306
315
  verified = await simulateForkPass(options.fork, planned, block, transactions, gasLimits, gasPrice, false, limits.headroomBps);
@@ -325,7 +334,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
325
334
  try {
326
335
  const ceilingLimits = discoveryGas;
327
336
  const retried = backend === "eth_simulateV1"
328
- ? await simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true)
337
+ ? await lifecycleStage(client, "simulation.envelope", () => simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true))
329
338
  : options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
330
339
  if (retried === undefined || retried.some((step) => !step.success)) {
331
340
  await assertLifecycleBlock(client, block, planned.chainId);
@@ -368,13 +377,14 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
368
377
  }
369
378
  }
370
379
  const estimates: LifecycleSimulationStep[] = [];
371
- const balance = rpcQuantity(await lifecycleRpc(client, "eth_getBalance", [planned.account, toHex(block.number)]), "creator native balance");
380
+ const balance = rpcQuantity(await lifecyclePinnedRpc(client, "eth_getBalance", [planned.account, toHex(block.number)], block), "creator native balance");
372
381
  let remainingBalance = balance;
373
382
  let affordabilityFailure: string | undefined;
383
+ const feeContext = configuration.estimateDataFee === undefined || sourceClient === client ? context : { ...context, client: sourceClient };
374
384
  for (let index = 0; index < verifiedSteps.length; index += 1) {
375
385
  const step = verifiedSteps[index]; const transaction = transactions[index]; const gasLimit = admittedGasLimits[index];
376
386
  if (step === undefined || transaction === undefined || gasLimit === undefined) throw new LifecyclePlanningError("INVALID_SIMULATION", "Missing verified fee context");
377
- const dataFee = limits.protocol === "nitro" ? 0n : configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit }, context);
387
+ const dataFee = limits.protocol === "nitro" ? 0n : configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit }, feeContext);
378
388
  if (dataFee !== undefined && (typeof dataFee !== "bigint" || dataFee < 0n || dataFee >= 1n << 256n)) throw new LifecyclePlanningError("INVALID_DATA_FEE", "Data-fee oracle must return a nonnegative uint256 bigint amount");
379
389
  // Nitro gas includes poster charges. Never add the quoted poster fee again.
380
390
  const executionFee = gasLimit * gasPrice;
@@ -4,9 +4,28 @@ import type {
4
4
  MarketLiveStateV1, PreparedMarketV1, ProfileRegistrationV1, ProfileTopologyV1, LaunchEnvelopeV2, LifecycleDeveloperTerms,
5
5
  } from "./schema.js";
6
6
 
7
+ /** Local opt-in timing only: never contains request arguments, addresses, results or errors. */
8
+ export type LifecycleDiagnosticStage =
9
+ | "plan" | "plan.context" | "plan.domain" | "plan.progress" | "plan.inputs" | "rpc"
10
+ | "profile.certification" | "simulation.context" | "simulation.nitro"
11
+ | "simulation.poster" | "simulation.measure" | "simulation.replay" | "simulation.envelope";
12
+ export type LifecycleDiagnosticEvent = {
13
+ stage: LifecycleDiagnosticStage;
14
+ phase: "queued" | "start" | "success" | "failure" | "reuse";
15
+ durationMs?: number;
16
+ queueMs?: number;
17
+ requestId?: number;
18
+ method?: string;
19
+ };
20
+ export type LifecycleDiagnosticListener = (event: LifecycleDiagnosticEvent) => void | Promise<void>;
21
+
7
22
  /** viem public clients or a raw JSON-RPC client; neither selects a transport. */
8
23
  export type LifecycleRpcClient = {
9
24
  request(args: { method: string; params?: readonly unknown[] }): Promise<unknown>;
25
+ /** Opt in to bounded parallel reads only when the supplied transport batches concurrent requests. */
26
+ supportsReadBatching?: boolean;
27
+ /** Optional local observer. Listener failures cannot change execution or admission. */
28
+ onDiagnostic?: LifecycleDiagnosticListener;
10
29
  };
11
30
  export type LifecycleBlock = { number: bigint; hash: Hash; timestamp: bigint; gasLimit: bigint; baseFeePerGas?: bigint };
12
31
  export type LifecycleLimitContext = { client: LifecycleRpcClient; account: Address; orchestrator: Address; block: LifecycleBlock; chainId: bigint };