@black-market/sdk 0.6.4 → 0.7.0

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.
Files changed (82) hide show
  1. package/README.md +28 -4
  2. package/dist/addresses.d.ts +1 -1
  3. package/dist/addresses.js +4 -4
  4. package/dist/launch.d.ts +61 -0
  5. package/dist/launch.d.ts.map +1 -1
  6. package/dist/launch.js +171 -6
  7. package/dist/launch.js.map +1 -1
  8. package/dist/lifecycle/abi.d.ts +1999 -253
  9. package/dist/lifecycle/abi.d.ts.map +1 -1
  10. package/dist/lifecycle/abi.js +43 -14
  11. package/dist/lifecycle/abi.js.map +1 -1
  12. package/dist/lifecycle/allocations.d.ts +11 -0
  13. package/dist/lifecycle/allocations.d.ts.map +1 -0
  14. package/dist/lifecycle/allocations.js +36 -0
  15. package/dist/lifecycle/allocations.js.map +1 -0
  16. package/dist/lifecycle/calibration.d.ts +19 -0
  17. package/dist/lifecycle/calibration.d.ts.map +1 -0
  18. package/dist/lifecycle/calibration.js +65 -0
  19. package/dist/lifecycle/calibration.js.map +1 -0
  20. package/dist/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.d.ts +11 -0
  21. package/dist/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.d.ts.map +1 -0
  22. package/dist/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.js +10 -0
  23. package/dist/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.js.map +1 -0
  24. package/dist/lifecycle/index.d.ts +4 -2
  25. package/dist/lifecycle/index.d.ts.map +1 -1
  26. package/dist/lifecycle/index.js +4 -2
  27. package/dist/lifecycle/index.js.map +1 -1
  28. package/dist/lifecycle/markets.d.ts +170 -6
  29. package/dist/lifecycle/markets.d.ts.map +1 -1
  30. package/dist/lifecycle/markets.js +164 -37
  31. package/dist/lifecycle/markets.js.map +1 -1
  32. package/dist/lifecycle/planner.d.ts +7 -14
  33. package/dist/lifecycle/planner.d.ts.map +1 -1
  34. package/dist/lifecycle/planner.js +354 -400
  35. package/dist/lifecycle/planner.js.map +1 -1
  36. package/dist/lifecycle/presets.d.ts +62 -0
  37. package/dist/lifecycle/presets.d.ts.map +1 -0
  38. package/dist/lifecycle/presets.js +195 -0
  39. package/dist/lifecycle/presets.js.map +1 -0
  40. package/dist/lifecycle/progress.d.ts +5 -1
  41. package/dist/lifecycle/progress.d.ts.map +1 -1
  42. package/dist/lifecycle/progress.js +212 -53
  43. package/dist/lifecycle/progress.js.map +1 -1
  44. package/dist/lifecycle/rpc.d.ts +13 -2
  45. package/dist/lifecycle/rpc.d.ts.map +1 -1
  46. package/dist/lifecycle/rpc.js +92 -8
  47. package/dist/lifecycle/rpc.js.map +1 -1
  48. package/dist/lifecycle/simulation.d.ts +14 -4
  49. package/dist/lifecycle/simulation.d.ts.map +1 -1
  50. package/dist/lifecycle/simulation.js +383 -116
  51. package/dist/lifecycle/simulation.js.map +1 -1
  52. package/dist/lifecycle/transactions.d.ts +6 -0
  53. package/dist/lifecycle/transactions.d.ts.map +1 -0
  54. package/dist/lifecycle/transactions.js +40 -0
  55. package/dist/lifecycle/transactions.js.map +1 -0
  56. package/dist/lifecycle/types.d.ts +39 -2
  57. package/dist/lifecycle/types.d.ts.map +1 -1
  58. package/dist/lifecycle/types.js.map +1 -1
  59. package/docs/launch-lifecycle.md +228 -54
  60. package/examples/README.md +108 -18
  61. package/examples/generate-lifecycle-commitment-fixture.mjs +3 -1
  62. package/examples/launch-example-support.mjs +1 -1
  63. package/examples/launch-example.mjs +73 -26
  64. package/examples/launch-lifecycle-smoke.mjs +13 -11
  65. package/examples/launch.mjs +12 -11
  66. package/examples/quickstart.mjs +1 -1
  67. package/package.json +1 -1
  68. package/src/addresses.ts +4 -4
  69. package/src/launch.ts +213 -6
  70. package/src/lifecycle/abi.ts +43 -14
  71. package/src/lifecycle/allocations.ts +35 -0
  72. package/src/lifecycle/calibration.ts +59 -0
  73. package/src/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.ts +11 -0
  74. package/src/lifecycle/index.ts +4 -2
  75. package/src/lifecycle/markets.ts +169 -35
  76. package/src/lifecycle/planner.ts +326 -330
  77. package/src/lifecycle/presets.ts +243 -0
  78. package/src/lifecycle/progress.ts +193 -50
  79. package/src/lifecycle/rpc.ts +97 -9
  80. package/src/lifecycle/simulation.ts +370 -109
  81. package/src/lifecycle/transactions.ts +34 -0
  82. package/src/lifecycle/types.ts +35 -3
@@ -1,8 +1,59 @@
1
- import { decodeEventLog, decodeFunctionResult, encodeFunctionData, encodeFunctionResult, toHex, type Hex } from "viem";
2
- import { launchLifecycleAbi, lifecycleErc20Abi } from "./abi.js";
3
- import { LifecyclePhase, type LaunchProgressV1, type LaunchReceiptV1 } from "./schema.js";
4
- import { assertLifecycleBlock, lifecyclePinnedRpc, lifecycleRpc, lifecycleSourceClient, lifecycleStage, nitroArbSys, nitroArbSysAbi, nitroGasInfo, nitroGasInfoAbi, readLifecycleBlock, readNitroPosterGas, resolveLifecycleLimits, rpcHex, rpcObject, rpcQuantity } from "./rpc.js";
5
- import { LifecyclePlanningError, type ControlledLifecycleFork, type LifecycleBlock, type LifecycleLimitContext, type LifecycleLimits, type LifecyclePostcondition, type LifecycleRpcClient, type LifecycleSimulation, type LifecycleSimulationStep, type LifecycleTransaction, type PlannedLaunch, type ResolvedLifecycleLimits, type SimulateLaunchPlanOptions } from "./types.js";
1
+ import { decodeErrorResult, decodeEventLog, decodeFunctionResult, encodeFunctionData, encodeFunctionResult, parseAbi, toHex, type Hex } from "viem";
2
+ import { fixedFeePoolHookV1Abi, launchLifecycleAbi, lifecycleErc20Abi } from "./abi.js";
3
+ import { hashLaunchIdentity, hashLaunchPlan, LifecyclePhase, type LaunchPlanV1, type LaunchProgressV1, type LaunchReceiptV1 } from "./schema.js";
4
+ import { assertLifecycleBlock, assertLifecycleReadClientOpen, lifecycleFailureReason, lifecyclePinnedRpc, lifecycleRpc, lifecycleSourceClient, lifecycleStage, nitroArbSys, nitroArbSysAbi, nitroGasInfo, nitroGasInfoAbi, readLifecycleBlock, readLifecycleChainId, readNitroPosterGas, resolveLifecycleLimits, rpcHex, rpcObject, rpcQuantity, type LifecycleLimitObservation } from "./rpc.js";
5
+ import { LifecyclePlanningError, type ControlledLifecycleFork, type LifecycleBlock, type LifecycleLimitContext, type LifecyclePostcondition, type LifecycleRpcClient, type LifecycleSimulation, type LifecycleSimulationStep, type LifecycleTransaction, type PlannedLaunch, type ResolvedLifecycleLimits, type SimulateLaunchPlanOptions } from "./types.js";
6
+ import { decodeAbyssLifecycleMarketConfig, decodePoolBoundV4LifecycleMarketConfig, decodeV4LifecycleMarketConfig, isPoolBoundV4ConfigVersion } from "./markets.js";
7
+
8
+ function committedPositionCount(plan: LaunchPlanV1): number {
9
+ let count = 0;
10
+ for (const market of plan.markets) {
11
+ if (market.configVersion === 1) count += decodeAbyssLifecycleMarketConfig(market.config).positions.length;
12
+ else if (market.configVersion === 4) count += decodeV4LifecycleMarketConfig(market.config).positions.length;
13
+ else if (isPoolBoundV4ConfigVersion(market.configVersion)) count += decodePoolBoundV4LifecycleMarketConfig(market.config, market.configVersion).positions.length;
14
+ else throw new LifecyclePlanningError("UNSUPPORTED_CONFIG_VERSION", "Activation requires a supported committed position schema");
15
+ }
16
+ return count;
17
+ }
18
+
19
+ const executionGuardErrors = [
20
+ ...fixedFeePoolHookV1Abi.filter((item) => item.type === "error"),
21
+ ...parseAbi([
22
+ "error WrongDomain()", "error PlanMismatch()", "error LaunchAlreadyExists()", "error InvalidMode()",
23
+ "error InvalidPhase()", "error InvalidPreparationOrder()", "error DeadlineExpired()", "error InvalidBinding()",
24
+ "error InvalidMarket()", "error InsufficientEscrow()", "error InvalidPositions()", "error InvalidPlan()",
25
+ "error InvalidFunding()", "error InvalidFeePolicy()", "error InvalidBuy()", "error DuplicateMarket()",
26
+ "error IneligibleImplementation()", "error QuoteDebtForbidden()", "error SlippageExceeded()", "error FeeBelowMinimum()",
27
+ "error DeploymentFailed()", "error Error(string reason)", "error Panic(uint256 code)",
28
+ ]),
29
+ ];
30
+
31
+ function executionFailure(returnData: Hex | undefined, error: unknown): Pick<LifecycleSimulationStep, "failureCategory" | "decodedError" | "nativeErrorCode" | "nativeErrorKind" | "nativeErrorDataBytes" | "nativeGasCapped" | "error"> {
32
+ const native = error !== null && typeof error === "object" && !Array.isArray(error) ? error as Record<string, unknown> : undefined;
33
+ let nativeData: unknown = native?.data;
34
+ for (let depth = 0; depth < 3 && nativeData !== null && typeof nativeData === "object" && !Array.isArray(nativeData); depth += 1) nativeData = (nativeData as Record<string, unknown>).data;
35
+ const nativeErrorDataBytes = typeof nativeData === "string" && /^0x(?:[0-9a-fA-F]{2})*$/.test(nativeData) ? (nativeData.length - 2) / 2 : undefined;
36
+ const nativeErrorCode = typeof native?.code === "number" && Number.isSafeInteger(native.code) ? native.code : undefined;
37
+ const message = typeof native?.message === "string" ? native.message : "";
38
+ const nativeGasCapped = message.endsWith(" (gas limit was capped by the RPC server's global gas cap)");
39
+ // Native eth_simulateV1 uses -32015 for VM errors; -32000 is also used by RPC clients.
40
+ // Empty data and an explicit OOG report are required. Malformed data or proximity is not proof.
41
+ const outOfGas = (returnData === undefined || returnData === "0x") && (native?.data === undefined || nativeErrorDataBytes === 0) &&
42
+ (nativeErrorCode === -32015 || nativeErrorCode === -32000) && /^out of gas\b/i.test(message);
43
+ const nativeErrorKind = native === undefined ? "missing" : outOfGas ? "out-of-gas" : nativeErrorCode === 3 ? "execution-reverted" : nativeErrorCode === -32015 ? "vm-error" : "other";
44
+ const diagnostic = { nativeErrorCode, nativeErrorKind, nativeErrorDataBytes, nativeGasCapped } as const;
45
+ const data = returnData === undefined || returnData === "0x" ? nativeData : returnData;
46
+ if (typeof data === "string" && /^0x(?:[0-9a-fA-F]{2}){4,}$/.test(data)) {
47
+ try {
48
+ const { errorName } = decodeErrorResult({ abi: executionGuardErrors, data: data as Hex });
49
+ return { ...diagnostic, failureCategory: errorName === "DeploymentFailed" ? "opaque" : "semantic", decodedError: errorName, error: `Execution reverted (${errorName})` };
50
+ } catch { return { ...diagnostic, failureCategory: "opaque", error: "Execution reverted without a reviewed error" }; }
51
+ }
52
+ if (outOfGas) return { ...diagnostic, failureCategory: nativeGasCapped ? "source" : "capacity", error: nativeGasCapped ? "Native execution ran out of gas under the reported RPC gas cap" : "Native execution ran out of gas" };
53
+ return { ...diagnostic, failureCategory: "opaque", error: nativeErrorKind === "execution-reverted" ? "Native execution reverted without a reviewed error" : "Execution failed without a reviewed error" };
54
+ }
55
+
56
+ const nitroProofsByInvocation = new WeakMap<LifecycleRpcClient, Map<string, Promise<void>>>();
6
57
 
7
58
  function conditionRequest(condition: LifecyclePostcondition, planned: PlannedLaunch): { to: Hex; data: Hex } {
8
59
  if (condition.kind === "allowance") return {
@@ -85,8 +136,8 @@ async function simulateRpcPass(client: LifecycleRpcClient, planned: PlannedLaunc
85
136
  if (gasRequired < gasUsed || gasRequired > (gasLimits[index] ?? 0n)) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Simulated gas use is outside the exact requested envelope");
86
137
  const returnData = rpcHex(call.returnData, "simulated return data");
87
138
  const success = rpcQuantity(call.status, "simulated status") === 1n;
88
- let error: string | undefined;
89
- if (!success) error = call.error === undefined ? `Execution reverted: ${returnData}` : JSON.stringify(call.error);
139
+ const failureDiagnostic = success ? undefined : executionFailure(returnData, call.error);
140
+ let error = failureDiagnostic?.error;
90
141
  if (success) {
91
142
  try {
92
143
  // Verification reads must not become synthetic transactions that consume creator nonce/fees.
@@ -106,26 +157,40 @@ async function simulateRpcPass(client: LifecycleRpcClient, planned: PlannedLaunc
106
157
  } else if (transaction.kind === "atomic" || transaction.kind === "activate") {
107
158
  const functionName = transaction.kind === "atomic" ? "launchAtomic" : "activateLaunch";
108
159
  const receipt = decodeFunctionResult({ abi: launchLifecycleAbi, functionName, data: returnData }) as LaunchReceiptV1;
109
- if (receipt.planHash.toLowerCase() !== planned.planHash.toLowerCase() || receipt.launchId.toLowerCase() !== planned.launchId.toLowerCase() || receipt.token.toLowerCase() !== planned.predictedToken.toLowerCase() || receipt.marketCount !== planned.plan.markets.length || receipt.quoteSpent.length !== planned.plan.buys.length || receipt.tokenOut.length !== planned.plan.buys.length || !events.some((event) => event.eventName === "LaunchActivated" && event.args.planHash.toLowerCase() === planned.planHash.toLowerCase() && event.args.token.toLowerCase() === planned.predictedToken.toLowerCase())) throw new LifecyclePlanningError("POSTCONDITION_FAILED", "Simulation did not atomically complete the committed launch");
160
+ const positionCount = committedPositionCount(planned.plan);
161
+ if (receipt.planHash.toLowerCase() !== planned.planHash.toLowerCase() || receipt.launchId.toLowerCase() !== planned.launchId.toLowerCase() || receipt.token.toLowerCase() !== planned.predictedToken.toLowerCase() || receipt.marketCount !== planned.plan.markets.length || receipt.positionCount !== positionCount || receipt.quoteSpent.length !== planned.plan.buys.length || receipt.tokenOut.length !== planned.plan.buys.length || !events.some((event) => event.eventName === "LaunchActivated" && event.args.launchId.toLowerCase() === planned.launchId.toLowerCase() && event.args.planHash.toLowerCase() === planned.planHash.toLowerCase() && event.args.token.toLowerCase() === planned.predictedToken.toLowerCase() && event.args.marketCount === planned.plan.markets.length && event.args.positionCount === positionCount)) throw new LifecyclePlanningError("POSTCONDITION_FAILED", "Simulation did not atomically complete every committed market, position and ordered buy");
110
162
  for (let buyIndex = 0; buyIndex < planned.plan.buys.length; buyIndex += 1) {
111
163
  const buy = planned.plan.buys[buyIndex];
112
164
  if (buy === undefined || (receipt.quoteSpent[buyIndex] ?? buy.quoteAmountIn + 1n) > buy.quoteAmountIn || (receipt.tokenOut[buyIndex] ?? 0n) < buy.minTokenOut) throw new LifecyclePlanningError("POSTCONDITION_FAILED", "Simulation did not satisfy every ordered buy's budget and output minimum");
113
165
  }
114
166
  } else if (transaction.kind === "cancel" && !events.some((event) => event.eventName === "LaunchCancelled" && event.args.launchId.toLowerCase() === planned.launchId.toLowerCase() && event.args.creator.toLowerCase() === planned.account.toLowerCase())) throw new LifecyclePlanningError("POSTCONDITION_FAILED", "Simulation did not cancel the exact committed launch");
115
167
  else if ((transaction.kind === "approve" || transaction.kind === "approve-reset") && returnData !== "0x" && decodeFunctionResult({ abi: lifecycleErc20Abi, functionName: "approve", data: returnData }) !== true) throw new LifecyclePlanningError("POSTCONDITION_FAILED", "Funding approval returned failure");
116
- } catch (failure) { error = failure instanceof Error ? failure.message : String(failure); }
168
+ } catch (failure) { error = failure instanceof LifecyclePlanningError ? failure.message : "Simulation returned an invalid committed postcondition"; }
117
169
  }
118
- steps.push({ transactionId: transaction.id, success: success && error === undefined, gasUsed, gasRequired, gasLimit: gasLimits[index], returnData, error });
170
+ steps.push({ transactionId: transaction.id, success: success && error === undefined, gasUsed, gasRequired, gasLimit: gasLimits[index], returnData, error, ...failureDiagnostic, ...(success && error !== undefined ? { failureCategory: "postcondition" as const } : {}) });
119
171
  if (!success || error !== undefined) break;
120
172
  }
121
173
  return steps;
122
174
  }
123
- /** A failure that consumed nearly the whole limit is treated as possible gas-envelope
124
- * exhaustion (EIP-150 retention starves forwarded sub-calls; the surfaced revert bytes
125
- * may be a wrapped inner bubble rather than empty). Discovery is safe: a genuine
126
- * business revert also fails at the exact ceiling, so it can never be masked. */
175
+ /** A decoded semantic refusal or opaque deployment wrapper is never gas evidence. */
127
176
  function looksGasExhausted(step: LifecycleSimulationStep, gasLimit: bigint): boolean {
128
- return !step.success && step.gasUsed !== undefined && step.gasUsed >= gasLimit - gasLimit / 64n;
177
+ return !step.success && step.failureCategory === "capacity" && step.gasUsed !== undefined && step.gasUsed >= gasLimit - gasLimit / 64n;
178
+ }
179
+
180
+ function simulationFailureKind(failure: unknown): "abort" | "out-of-gas" | "execution" | "source" {
181
+ for (let depth = 0; depth < 8 && failure !== null && typeof failure === "object"; depth += 1) {
182
+ const error = failure as { name?: unknown; code?: unknown; status?: unknown; statusCode?: unknown; message?: unknown; details?: unknown; data?: unknown; cause?: unknown };
183
+ if (error.name === "AbortError") return "abort";
184
+ const status = error.status ?? error.statusCode;
185
+ if (typeof status === "number" && status >= 400 && status <= 599) return "source";
186
+ const message = typeof error.details === "string" ? error.details : typeof error.message === "string" ? error.message : "";
187
+ const execution = executionFailure(undefined, { ...error, message });
188
+ if (execution.nativeErrorKind === "out-of-gas" && execution.nativeGasCapped) return "source";
189
+ if (execution.failureCategory === "capacity") return "out-of-gas";
190
+ if (error.code === 3 || error.code === -32000 && /^execution (?:failed|reverted)\b/i.test(message)) return "execution";
191
+ failure = error.cause;
192
+ }
193
+ return "source";
129
194
  }
130
195
 
131
196
  async function waitForkReceipt(client: LifecycleRpcClient, transactionHash: Hex, timeoutMs: number): Promise<Record<string, unknown>> {
@@ -196,7 +261,7 @@ async function simulateForkPass(fork: ControlledLifecycleFork, planned: PlannedL
196
261
  const activated = events.filter((event) => event.eventName === "LaunchActivated");
197
262
  const buys = events.filter((event) => event.eventName === "InitialBuyExecuted");
198
263
  if (activated.length !== 1 || !activated.some((event) => event.eventName === "LaunchActivated" && event.args.launchId.toLowerCase() === planned.launchId.toLowerCase() && event.args.planHash.toLowerCase() === planned.planHash.toLowerCase() && event.args.token.toLowerCase() === planned.predictedToken.toLowerCase()) ||
199
- progress.phase !== LifecyclePhase.Active || progress.planHash.toLowerCase() !== planned.planHash.toLowerCase() || progress.token.toLowerCase() !== planned.predictedToken.toLowerCase() ||
264
+ progress.phase !== LifecyclePhase.Active || progress.planHash.toLowerCase() !== planned.planHash.toLowerCase() || progress.token.toLowerCase() !== planned.predictedToken.toLowerCase() || progress.marketCount !== planned.plan.markets.length || progress.positionCount !== committedPositionCount(planned.plan) ||
200
265
  buys.length !== planned.plan.buys.length) throw new LifecyclePlanningError("POSTCONDITION_FAILED", "Actual fork activation differs from the exact committed launch");
201
266
  const quoteSpent: bigint[] = []; const tokenOut: bigint[] = [];
202
267
  for (let buyIndex = 0; buyIndex < buys.length; buyIndex += 1) {
@@ -219,7 +284,7 @@ async function simulateForkPass(fork: ControlledLifecycleFork, planned: PlannedL
219
284
  }
220
285
  steps.push({ transactionId: transaction.id, success: true, gasUsed, gasLimit: gas, gasRequired, returnData });
221
286
  } catch (failure) {
222
- steps.push({ transactionId: transaction.id, success: false, gasLimit: gas, error: failure instanceof Error ? failure.message : String(failure) });
287
+ steps.push({ transactionId: transaction.id, success: false, gasLimit: gas, error: lifecycleFailureReason(failure) });
223
288
  break;
224
289
  }
225
290
  }
@@ -233,91 +298,221 @@ async function simulateForkPass(fork: ControlledLifecycleFork, planned: PlannedL
233
298
  }
234
299
  }
235
300
 
236
- /** Internal one-shot continuation. Only SDK reads can produce its context; callers
237
- * cannot supply resolved policy or account/chain observations as authority. */
238
- export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, block: LifecycleBlock, hasTransactions = true): (transactions: readonly LifecycleTransaction[]) => Promise<LifecycleSimulation> {
239
- const { client, planned } = options;
240
- const sourceClient = lifecycleSourceClient(client);
241
- const blockNumber = block.number, blockHash = block.hash;
242
- const context = { client, account: planned.account, orchestrator: planned.plan.orchestrator, block, chainId: planned.chainId };
301
+ type SimulationObservations = [LifecycleLimitObservation, unknown, unknown, unknown];
302
+ /** Internal, operation-local fee envelope for the already matched immediate step. */
303
+ export type NextTransactionEnvelope = Readonly<{ gas: bigint; gasPrice: bigint }>;
304
+ export type PreparedLaunchSimulation = ((transactions: readonly LifecycleTransaction[], nextEnvelope?: NextTransactionEnvelope) => Promise<LifecycleSimulation>) & {
305
+ /** Unadmitted diagnostic results only; final admission always performs validated replay. */
306
+ measure(transactions: readonly LifecycleTransaction[]): Promise<LifecycleSimulation>;
307
+ close(): void;
308
+ };
309
+
310
+ function observedFields<T extends object>(value: T): () => boolean {
311
+ const keys = Object.keys(value) as (keyof T)[];
312
+ const values = keys.map((key) => value[key]);
313
+ return () => keys.length === Object.keys(value).length && keys.every((key, index) => value[key] === values[index]);
314
+ }
315
+
316
+ /** Internal continuation owned by one SDK read invocation. Only its positive
317
+ * capability proof is reused; every candidate executes its entire economic sequence. */
318
+ export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, block: LifecycleBlock, hasTransactions = true): PreparedLaunchSimulation {
319
+ const { client, planned, fork } = options;
320
+ const signal = options.signal;
321
+ if (signal?.aborted) throw Object.assign(new Error("Lifecycle simulation cancelled"), { name: "AbortError" });
322
+ const sourceClient = lifecycleSourceClient(client), sourceRequest = sourceClient.request;
323
+ const sourceBatching = sourceClient.supportsReadBatching;
324
+ const reusable = assertLifecycleReadClientOpen(client);
325
+ const plan = planned.plan, planHash = planned.planHash, launchId = planned.launchId;
326
+ const optionsLimits = options.limits, plannedLimits = planned.limits;
327
+ const policy = optionsLimits === undefined ? plannedLimits : optionsLimits;
328
+ const blockMatches = observedFields(block);
329
+ const forkMatches = fork === undefined ? undefined : observedFields(fork);
330
+ const forkRequest = fork?.client.request;
331
+ const plannedMatches = observedFields(planned);
332
+ const context: LifecycleLimitContext = Object.freeze({ client, account: planned.account, orchestrator: plan.orchestrator, block: Object.freeze({ ...block }), chainId: planned.chainId });
333
+ let closed = false, active = false, consumed = false, capabilityProved = false;
334
+ let capabilityPending: Promise<void> | undefined;
335
+ const assertContext = () => {
336
+ if (closed) throw new LifecyclePlanningError("INVALID_SIMULATION", "Prepared simulation continuation has closed");
337
+ if (signal?.aborted) throw Object.assign(new Error("Lifecycle simulation cancelled"), { name: "AbortError" });
338
+ assertLifecycleReadClientOpen(client);
339
+ if (options.client !== client || options.planned !== planned || options.limits !== optionsLimits || options.fork !== fork || options.signal !== signal ||
340
+ lifecycleSourceClient(client) !== sourceClient || sourceClient.request !== sourceRequest || !plannedMatches() || !blockMatches() ||
341
+ sourceClient.supportsReadBatching !== sourceBatching ||
342
+ planned.plan !== plan || planned.account !== context.account || plan.orchestrator !== context.orchestrator || planned.chainId !== context.chainId ||
343
+ planned.limits !== plannedLimits || forkMatches !== undefined && !forkMatches() || fork?.client.request !== forkRequest) {
344
+ throw new LifecyclePlanningError("LIMIT_CONTEXT_MISMATCH", "Prepared simulation differs from its source/account/core/plan/header/policy context");
345
+ }
346
+ if (hashLaunchPlan(plan).toLowerCase() !== planHash.toLowerCase() || hashLaunchIdentity(plan).toLowerCase() !== launchId.toLowerCase()) {
347
+ throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed after its simulation context was bound");
348
+ }
349
+ };
243
350
  const pending = (async () => {
244
- if (options.fork !== undefined && lifecycleSourceClient(options.fork.client) === sourceClient) throw new LifecyclePlanningError("UNSAFE_FORK", "Source RPC must never receive controlled simulation transactions/snapshots/resets");
351
+ if (fork !== undefined && lifecycleSourceClient(fork.client) === sourceClient) throw new LifecyclePlanningError("UNSAFE_FORK", "Source RPC must never receive controlled simulation transactions/snapshots/resets");
245
352
  return lifecycleStage(client, "simulation.context", () => Promise.all([
246
- resolveLifecycleLimits(context, options.limits === undefined ? planned.limits : options.limits),
247
- lifecycleRpc(client, "eth_chainId"),
248
- lifecyclePinnedRpc(client, "eth_getCode", [context.account, toHex(block.number)], block),
353
+ resolveLifecycleLimits(context, policy),
354
+ readLifecycleChainId(client),
355
+ lifecyclePinnedRpc(client, "eth_getCode", [context.account, toHex(context.block.number)], context.block),
249
356
  hasTransactions ? lifecycleRpc(client, "eth_gasPrice") : undefined,
250
357
  ]));
251
358
  })().then((observations) => ({ observations }), (failure: unknown) => ({ failure }));
252
- // Attach the failure handler before input validation can refuse or throw.
253
- // Preparation is read-only; no probe, measurement, replay or fork work starts.
254
- let consumed = false;
255
- return async (transactions) => {
256
- if (consumed) throw new LifecyclePlanningError("INVALID_SIMULATION", "Prepared simulation context is invocation-local and may only be consumed once");
359
+ // Refused inputs may discard this read-only preparation before it settles.
360
+ // Its rejection is handled immediately; no capability or economic work starts.
361
+ const run = async (transactions: readonly LifecycleTransaction[], diagnostic: boolean, nextEnvelope?: NextTransactionEnvelope): Promise<LifecycleSimulation> => {
362
+ if (active || consumed && !reusable || !hasTransactions && transactions.length !== 0) {
363
+ closed = true;
364
+ throw new LifecyclePlanningError("INVALID_SIMULATION", "Prepared candidates require sequential consumption by their SDK invocation owner");
365
+ }
366
+ active = true;
257
367
  consumed = true;
258
- const result = await pending;
259
- if ("failure" in result) throw result.failure;
260
- if (options.client !== client || options.planned !== planned || context.client !== client || context.block !== block || lifecycleSourceClient(client) !== sourceClient ||
261
- planned.account !== context.account || planned.plan.orchestrator !== context.orchestrator || planned.chainId !== context.chainId ||
262
- block.number !== blockNumber || block.hash !== blockHash) throw new LifecyclePlanningError("LIMIT_CONTEXT_MISMATCH", "Prepared simulation context differs from the bound source/chain/account/core/block");
263
- return simulateLaunchTransactionsWithContext(options, transactions, context, result.observations);
368
+ try {
369
+ assertContext();
370
+ const envelopeMatches = nextEnvelope === undefined ? undefined : observedFields(nextEnvelope);
371
+ const candidates = transactions.map((transaction) => ({
372
+ transaction, matches: observedFields(transaction),
373
+ dependencies: [...transaction.dependencies],
374
+ conditions: transaction.postconditions.map((condition) => ({ condition, matches: observedFields(condition) })),
375
+ }));
376
+ const result = await pending;
377
+ if ("failure" in result) throw result.failure;
378
+ const assertCurrent = () => {
379
+ assertContext();
380
+ result.observations[0].assertCurrent();
381
+ if (envelopeMatches !== undefined && !envelopeMatches()) throw new LifecyclePlanningError("INVALID_SIMULATION", "Reviewed gas envelope changed during its complete economic proof");
382
+ if (transactions.length !== candidates.length || candidates.some(({ transaction, matches, dependencies, conditions }, index) =>
383
+ transactions[index] !== transaction || !matches() || transaction.dependencies.length !== dependencies.length ||
384
+ dependencies.some((dependency, ordinal) => transaction.dependencies[ordinal] !== dependency) ||
385
+ transaction.postconditions.length !== conditions.length || conditions.some(({ condition, matches: conditionMatches }, ordinal) =>
386
+ transaction.postconditions[ordinal] !== condition || !conditionMatches()))) {
387
+ throw new LifecyclePlanningError("INVALID_SIMULATION", "Candidate transactions changed during their complete economic proof");
388
+ }
389
+ };
390
+ assertCurrent();
391
+ const proveBackend = async (limits: ResolvedLifecycleLimits, gasPrice: bigint) => {
392
+ assertCurrent();
393
+ if (capabilityProved) return;
394
+ if (capabilityPending === undefined) {
395
+ const key = JSON.stringify([context.chainId.toString(), context.account.toLowerCase(), context.orchestrator.toLowerCase(), context.block.number.toString(), context.block.hash.toLowerCase(), context.block.timestamp.toString(), context.block.gasLimit.toString(), limits.arbOSVersion?.toString(), limits.maxTxComputeGas?.toString(), limits.maxBlockComputeGas?.toString(), limits.executionGasCeiling.toString(), limits.maxSimulationGas?.toString(), gasPrice.toString()]);
396
+ let proofs = reusable ? nitroProofsByInvocation.get(client) : undefined;
397
+ if (reusable && proofs === undefined) { proofs = new Map(); nitroProofsByInvocation.set(client, proofs); }
398
+ const ownedProofs = proofs;
399
+ let nativeProof = ownedProofs?.get(key);
400
+ if (nativeProof === undefined) {
401
+ nativeProof = lifecycleStage(client, "simulation.nitro", () => proveNitroBackend(client, planned, context.block, limits, gasPrice));
402
+ ownedProofs?.set(key, nativeProof);
403
+ const pending = nativeProof;
404
+ void pending.catch(() => { if (ownedProofs?.get(key) === pending) ownedProofs?.delete(key); });
405
+ }
406
+ const proof = nativeProof.then(() => {
407
+ assertContext();
408
+ result.observations[0].assertCurrent();
409
+ capabilityProved = true;
410
+ });
411
+ capabilityPending = proof;
412
+ void proof.then(
413
+ () => { if (capabilityPending === proof) capabilityPending = undefined; },
414
+ () => { if (capabilityPending === proof) capabilityPending = undefined; },
415
+ );
416
+ }
417
+ await capabilityPending;
418
+ assertCurrent();
419
+ };
420
+ const simulation = await simulateLaunchTransactionsWithContext(options, transactions, context, result.observations, proveBackend, assertCurrent, diagnostic, nextEnvelope);
421
+ assertCurrent();
422
+ return simulation;
423
+ } catch (failure) {
424
+ closed = true;
425
+ capabilityProved = false;
426
+ capabilityPending = undefined;
427
+ throw failure;
428
+ } finally { active = false; }
264
429
  };
430
+ const simulate: PreparedLaunchSimulation = Object.assign(
431
+ (transactions: readonly LifecycleTransaction[], nextEnvelope?: NextTransactionEnvelope) => run(transactions, false, nextEnvelope),
432
+ { measure: (transactions: readonly LifecycleTransaction[]) => run(transactions, true),
433
+ close() { closed = true; capabilityProved = false; capabilityPending = undefined; } },
434
+ );
435
+ return simulate;
265
436
  }
266
437
 
267
- export async function simulateLaunchTransactions(options: SimulateLaunchPlanOptions, transactions: readonly LifecycleTransaction[], pinnedBlock?: LifecycleBlock): Promise<LifecycleSimulation> {
438
+ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOptions, transactions: readonly LifecycleTransaction[], pinnedBlock?: LifecycleBlock, nextEnvelope?: NextTransactionEnvelope): Promise<LifecycleSimulation> {
268
439
  // Standalone calls always produce fresh context, including live chain and fees.
269
440
  if (options.fork !== undefined && lifecycleSourceClient(options.fork.client) === lifecycleSourceClient(options.client)) throw new LifecyclePlanningError("UNSAFE_FORK", "Source RPC must never receive controlled simulation transactions/snapshots/resets");
270
441
  const block = pinnedBlock ?? await readLifecycleBlock(options.client);
271
- return prepareLaunchSimulation(options, block, transactions.length !== 0)(transactions);
442
+ const prepared = prepareLaunchSimulation(options, block, transactions.length !== 0);
443
+ try { return await prepared(transactions, nextEnvelope); }
444
+ finally { prepared.close(); }
272
445
  }
273
446
 
274
447
  async function simulateLaunchTransactionsWithContext(
275
448
  options: SimulateLaunchPlanOptions,
276
449
  transactions: readonly LifecycleTransaction[],
277
450
  context: LifecycleLimitContext,
278
- observations: [{ limits: ResolvedLifecycleLimits; configuration: LifecycleLimits }, unknown, unknown, unknown],
451
+ observations: SimulationObservations,
452
+ proveBackend: (limits: ResolvedLifecycleLimits, gasPrice: bigint) => Promise<void>,
453
+ assertCurrent: () => void,
454
+ diagnostic: boolean,
455
+ nextEnvelope?: NextTransactionEnvelope,
279
456
  ): Promise<LifecycleSimulation> {
280
457
  const { client, planned } = options;
281
458
  const sourceClient = lifecycleSourceClient(client);
282
459
  const { block } = context;
283
460
  const [{ limits, configuration }, chainId, accountCodeValue, gasPriceValue] = observations;
284
461
  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 };
285
- if (rpcQuantity(chainId, "chain ID") !== planned.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain changed before sequential simulation");
462
+ if (chainId !== planned.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain changed before sequential simulation");
286
463
  const accountCode = rpcHex(accountCodeValue, "account code");
287
- 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" };
464
+ if (accountCode !== "0x") {
465
+ await assertLifecycleBlock(client, block, planned.chainId);
466
+ return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: "Direct EOA transaction simulation cannot prove smart-account signature/execution behavior" };
467
+ }
288
468
  for (const transaction of transactions) {
289
469
  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");
290
- 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" };
470
+ if (limits.maxCalldataBytes !== undefined && (transaction.data.length - 2) / 2 > limits.maxCalldataBytes) {
471
+ await assertLifecycleBlock(client, block, planned.chainId);
472
+ return { ...base, executionProof: "unavailable", protocolFit: "failed", backend: "unavailable", admitted: false, steps: [], failedTransactionId: transaction.id, failureCategory: "capacity", capacityConstraint: "calldata", reason: "Exact calldata exceeds the supplied chain/account/RPC byte cap" };
473
+ }
291
474
  }
292
475
  if (transactions.length === 0) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, executionProof: "proved", protocolFit: "proved", backend: "unavailable", admitted: true, steps: [] }; }
293
- const gasPrice = rpcQuantity(gasPriceValue, "gas price");
476
+ const recommendedGasPrice = rpcQuantity(gasPriceValue, "gas price");
477
+ if (recommendedGasPrice >= 1n << 256n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Recommended gas price must be a uint256 quantity");
478
+ // Initial construction reserves twice the pinned base fee if the quote lags.
479
+ // Reviewed fees are not new recommendations: native replay must accept them
480
+ // under natural child fees, with no base-fee override or silent fee increase.
481
+ const baseFeeEnvelope = (block.baseFeePerGas ?? 0n) * 2n;
482
+ const gasPrice = nextEnvelope?.gasPrice ?? (recommendedGasPrice > baseFeeEnvelope ? recommendedGasPrice : baseFeeEnvelope);
294
483
  if (gasPrice >= 1n << 256n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Gas price must be a uint256 quantity");
484
+ if (nextEnvelope !== undefined && (typeof nextEnvelope.gas !== "bigint" || nextEnvelope.gas <= 0n || nextEnvelope.gas >= 1n << 64n ||
485
+ typeof nextEnvelope.gasPrice !== "bigint" || nextEnvelope.gasPrice < 0n || nextEnvelope.gasPrice >= 1n << 256n)) {
486
+ throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Reviewed gas and gas price must be exact positive uint64 gas and nonnegative uint256 price");
487
+ }
295
488
  const buffer = (gas: bigint) => (gas * BigInt(10000 + limits.headroomBps) + 9999n) / 10000n;
296
489
  const computeCeiling = limits.transactionGasCeiling !== undefined && limits.transactionGasCeiling < limits.executionGasCeiling ? limits.transactionGasCeiling : limits.executionGasCeiling;
297
- const capGas = transactions.map(() => computeCeiling);
298
- let parallelMeasurement: Promise<{ steps: LifecycleSimulationStep[] } | { failure: unknown }> | undefined;
490
+ // Nonnegative poster gas cannot make this complete discovery request smaller.
491
+ // A generic controlled fork retains the existing RPC-to-fork fallback.
492
+ if (limits.maxSimulationGas !== undefined && BigInt(transactions.length) * computeCeiling > limits.maxSimulationGas &&
493
+ (limits.protocol === "nitro" || options.fork === undefined)) {
494
+ assertCurrent();
495
+ await assertLifecycleBlock(client, block, planned.chainId);
496
+ return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], failureCategory: "capacity", capacityConstraint: "simulation-gas", reason: `Stateful sequential RPC simulation unavailable: Sequential request exceeds the current RPC aggregate simulation gas cap; use an isolated controlled fork. ${limits.protocol === "nitro" ? "Generic Anvil/Hardhat forks cannot prove ArbOS compute/poster metering." : "Dependent eth_call estimates are not proof."}` };
497
+ }
299
498
  const posterGas: bigint[] = [];
300
499
  if (limits.protocol === "nitro") {
301
- // The isolated capability probe, poster quote and permissive cap measurement
302
- // have no data dependency. Only an actually batching source overlaps all three.
303
- const nitroEvidence = Promise.all([
304
- lifecycleStage(client, "simulation.nitro", () => proveNitroBackend(client, planned, block, limits, gasPrice)),
305
- lifecycleStage(client, "simulation.poster", () => Promise.all(transactions.map(async (transaction) => buffer((await readNitroPosterGas(transaction, context)).posterGas)))),
306
- ]);
307
- if (sourceClient.supportsReadBatching === true) {
308
- // Handle settlement immediately so a failed measurement cannot escape while
309
- // the independent metering evidence is still pending. It is never admission.
310
- parallelMeasurement = lifecycleStage(client, "simulation.measure", () => simulateRpcPass(client, planned, block, transactions, capGas, limits, gasPrice, false))
311
- .then((steps) => ({ steps }), (failure: unknown) => ({ failure }));
312
- }
500
+ // Lightweight poster reads may overlap the isolated capability request.
501
+ // Economic work is not even queued until both prerequisites have succeeded.
313
502
  try {
314
- const [, estimates] = await nitroEvidence;
503
+ const [, estimates] = await Promise.all([
504
+ proveBackend(limits, gasPrice),
505
+ lifecycleStage(client, "simulation.poster", () => Promise.all(transactions.map(async (transaction) => buffer((await readNitroPosterGas(transaction, context)).posterGas)))),
506
+ ]);
315
507
  posterGas.push(...estimates);
316
508
  } catch (failure) {
509
+ if (simulationFailureKind(failure) === "abort") throw failure;
510
+ assertCurrent();
317
511
  await assertLifecycleBlock(client, block, planned.chainId);
318
- 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.` };
512
+ return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: `Native Nitro compute/poster proof unavailable: ${lifecycleFailureReason(failure)}. Generic Anvil/Hardhat forks cannot prove ArbOS metering.` };
319
513
  }
320
514
  }
515
+ assertCurrent();
321
516
  // The poster quote allocates gas only. The native exact replay enforces actual
322
517
  // compute hold; subtracting this approximate budget from gasUsed would not.
323
518
  const discoveryGas = transactions.map((_, index) => {
@@ -328,40 +523,53 @@ async function simulateLaunchTransactionsWithContext(
328
523
  });
329
524
  let backend: "eth_simulateV1" | "controlled-fork" = "eth_simulateV1";
330
525
  let measured: LifecycleSimulationStep[];
331
- // Permissive cap measurement is not proof; only the exact-gas validated replay admits a plan.
526
+ // A permissive measurement needs the legal complete compute + poster envelope.
527
+ // It is not proof: fixed compute/headroom checks and exact validated replay still follow.
332
528
  try {
333
- if (parallelMeasurement === undefined) measured = await lifecycleStage(client, "simulation.measure", () => simulateRpcPass(client, planned, block, transactions, capGas, limits, gasPrice, false));
334
- else {
335
- const observation = await parallelMeasurement;
336
- if ("failure" in observation) throw observation.failure;
337
- measured = observation.steps;
338
- }
529
+ measured = await lifecycleStage(client, "simulation.measure", () => simulateRpcPass(client, planned, block, transactions, discoveryGas, limits, gasPrice, false));
339
530
  }
340
531
  catch (failure) {
532
+ if (simulationFailureKind(failure) === "abort") throw failure;
533
+ assertCurrent();
341
534
  if (options.fork === undefined || limits.protocol === "nitro") {
342
535
  await assertLifecycleBlock(client, block, planned.chainId);
343
- return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: `Stateful sequential RPC simulation unavailable: ${failure instanceof Error ? failure.message : String(failure)}. ${limits.protocol === "nitro" ? "Generic Anvil/Hardhat forks cannot prove ArbOS compute/poster metering." : "Dependent eth_call estimates are not proof."}` };
536
+ return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: `Stateful sequential RPC simulation unavailable: ${lifecycleFailureReason(failure)}. ${limits.protocol === "nitro" ? "Generic Anvil/Hardhat forks cannot prove ArbOS compute/poster metering." : "Dependent eth_call estimates are not proof."}` };
344
537
  }
345
538
  backend = "controlled-fork";
346
- measured = await simulateForkPass(options.fork, planned, block, transactions, capGas, gasPrice, true, limits.headroomBps);
539
+ measured = await simulateForkPass(options.fork, planned, block, transactions, discoveryGas, gasPrice, true, limits.headroomBps);
347
540
  }
541
+ assertCurrent();
348
542
  const failed = measured.find((step) => !step.success);
349
- if (failed !== undefined) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, backend, admitted: false, steps: measured, reason: failed.error, failedTransactionId: failed.transactionId }; }
543
+ if (failed !== undefined) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, backend, admitted: false, steps: measured, reason: failed.error, failedTransactionId: failed.transactionId, failureCategory: failed.failureCategory ?? "opaque", capacityConstraint: failed.failureCategory === "capacity" ? "compute" : undefined }; }
544
+ if (diagnostic) {
545
+ await assertLifecycleBlock(client, block, planned.chainId);
546
+ return { ...base, backend, admitted: false, executionProof: "unavailable", steps: measured };
547
+ }
350
548
  const computeGas = measured.map((step) => buffer(step.gasRequired ?? step.gasUsed ?? computeCeiling));
351
549
  const gasLimits = computeGas.map((gas, index) => gas + (posterGas[index] ?? 0n));
352
550
  const exceeds = gasLimits.findIndex((gas, index) => gas <= 0n || gas >= 1n << 64n || (computeGas[index] ?? 0n) > limits.executionGasCeiling || limits.transactionGasCeiling !== undefined && gas > limits.transactionGasCeiling);
353
551
  if (exceeds >= 0) {
354
552
  await assertLifecycleBlock(client, block, planned.chainId);
355
- return { ...base, executionProof: "unavailable", protocolFit: "failed", backend, admitted: false, steps: measured, reason: "Execution compute/headroom or complete poster gas envelope is outside positive uint64 gas and current protocol/known transaction limits", failedTransactionId: transactions[exceeds]?.id };
553
+ return { ...base, executionProof: "unavailable", protocolFit: "failed", backend, admitted: false, steps: measured, failureCategory: "capacity", capacityConstraint: (computeGas[exceeds] ?? 0n) > limits.executionGasCeiling ? "compute" : "gas-envelope", reason: "Execution compute/headroom or complete poster gas envelope is outside positive uint64 gas and current protocol/known transaction limits", failedTransactionId: transactions[exceeds]?.id };
554
+ }
555
+ if (nextEnvelope !== undefined) {
556
+ if (nextEnvelope.gas < (gasLimits[0] ?? 0n) || limits.transactionGasCeiling !== undefined && nextEnvelope.gas > limits.transactionGasCeiling) {
557
+ await assertLifecycleBlock(client, block, planned.chainId);
558
+ return { ...base, executionProof: "unavailable", protocolFit: "failed", backend, admitted: false, steps: measured,
559
+ failureCategory: "capacity", capacityConstraint: "gas-envelope", failedTransactionId: transactions[0]?.id,
560
+ reason: "Reviewed gas cannot cover current minimum compute/headroom/poster requirements or exceeds the current transaction ceiling" };
561
+ }
562
+ gasLimits[0] = nextEnvelope.gas;
356
563
  }
357
- // Replay from the same initial state with exact headroom limits: gasUsed alone is not an EIP-150/gasleft proof.
358
- // eth_simulateV1 with validation raises an RPC error on any revert (a generic
359
- // -32000 "execution failed" carries no gasUsed/returnData), so a validated
360
- // replay failure surfaces as a thrown error here. Route it through envelope
361
- // discovery too: EIP-150 retention can starve a forwarded sub-call below the
362
- // buffered limit while the true envelope still fits the execution ceiling.
564
+ // Envelope discovery may enlarge future unfinished steps, never the reviewed
565
+ // immediate one. Every retry still executes the entire sequence at held price.
566
+ const envelopeDiscoveryGas = nextEnvelope === undefined ? discoveryGas : discoveryGas.map((gas, index) => index === 0 ? nextEnvelope.gas : gas);
567
+ // Replay the complete sequence from its original state at the exact buffered
568
+ // limits. Only explicit native OOG can discover one larger legal envelope;
569
+ // semantic, opaque and transport failures cannot authorize a capacity retry.
363
570
  let verified: LifecycleSimulationStep[];
364
571
  let validatedReplayError: string | undefined;
572
+ assertCurrent();
365
573
  try {
366
574
  if (backend === "eth_simulateV1") verified = await lifecycleStage(client, "simulation.replay", () => simulateRpcPass(client, planned, block, transactions, gasLimits, limits, gasPrice, true));
367
575
  else {
@@ -369,87 +577,140 @@ async function simulateLaunchTransactionsWithContext(
369
577
  verified = await simulateForkPass(options.fork, planned, block, transactions, gasLimits, gasPrice, false, limits.headroomBps);
370
578
  }
371
579
  } catch (failure) {
372
- const message = failure instanceof Error ? failure.message : String(failure);
580
+ const kind = simulationFailureKind(failure);
581
+ if (kind === "abort") throw failure;
582
+ assertCurrent();
583
+ const message = lifecycleFailureReason(failure);
373
584
  if (backend !== "eth_simulateV1") {
374
585
  await assertLifecycleBlock(client, block, planned.chainId);
375
586
  return { ...base, backend, admitted: false, steps: [], reason: `Exact-gas validated replay failed: ${message}` };
376
587
  }
588
+ if (kind !== "out-of-gas") {
589
+ await assertLifecycleBlock(client, block, planned.chainId);
590
+ return { ...base, backend: "unavailable", confidence: "provisional", executionProof: "unavailable", admitted: false, steps: measured, failureCategory: kind === "source" ? "source" : "opaque", reason: `Exact-gas validated replay unavailable: ${message}` };
591
+ }
377
592
  validatedReplayError = message;
378
593
  verified = [];
379
594
  }
595
+ assertCurrent();
380
596
  let verifiedSteps = verified;
381
597
  let admittedGasLimits = gasLimits;
382
598
  let finalFailure = verifiedSteps.find((step) => !step.success);
599
+ let finalFailureReason: string | undefined;
383
600
  if (finalFailure === undefined && validatedReplayError !== undefined) {
601
+ if (!envelopeDiscoveryGas.some((gas, index) => gas > (gasLimits[index] ?? gas))) {
602
+ await assertLifecycleBlock(client, block, planned.chainId);
603
+ return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling failed: ${validatedReplayError}` };
604
+ }
384
605
  // The buffered validated replay threw before yielding steps. Attempt one
385
606
  // explicit envelope-discovery replay at the exact execution ceiling on the
386
607
  // same pinned block; headroom is never silently raised — the carried gas is
387
608
  // the validated envelope itself.
609
+ assertCurrent();
388
610
  try {
389
- const ceilingLimits = discoveryGas;
611
+ const ceilingLimits = envelopeDiscoveryGas;
390
612
  const retried = backend === "eth_simulateV1"
391
613
  ? await lifecycleStage(client, "simulation.envelope", () => simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true))
392
614
  : options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
393
- if (retried === undefined || retried.some((step) => !step.success)) {
615
+ const retryFailureIndex = retried?.findIndex((step) => !step.success) ?? -1;
616
+ const retryFailure = retried?.[retryFailureIndex];
617
+ if (retried === undefined) {
394
618
  await assertLifecycleBlock(client, block, planned.chainId);
395
- return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling still failed: ${retried?.find((step) => !step.success)?.error ?? validatedReplayError}; final mint/lock/all-buys/public-opening is indivisible and will not be partitioned`, failedTransactionId: transactions[0]?.id };
619
+ return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling still failed: ${validatedReplayError}` };
396
620
  }
397
621
  admittedGasLimits = ceilingLimits;
398
622
  verifiedSteps = retried;
399
- finalFailure = undefined;
623
+ finalFailure = retryFailure;
400
624
  validatedReplayError = undefined;
401
- } catch {
625
+ if (retryFailure !== undefined) {
626
+ const kind = transactions[retryFailureIndex]?.kind;
627
+ const indivisible = kind === "activate" || kind === "atomic";
628
+ finalFailureReason = `Validated execution at the exact current gas ceiling still failed: ${retryFailure.error ?? "Execution reverted"}${indivisible ? "; final mint/lock/all-buys/public-opening is indivisible and will not be partitioned" : ""}`;
629
+ }
630
+ } catch (failure) {
631
+ const kind = simulationFailureKind(failure);
632
+ if (kind === "abort") throw failure;
633
+ assertCurrent();
402
634
  await assertLifecycleBlock(client, block, planned.chainId);
403
- return { ...base, backend, admitted: false, steps: measured, reason: `Validated buffered replay failed (${validatedReplayError}) and envelope discovery at the exact gas ceiling was rejected by the simulation backend; final mint/lock/all-buys/public-opening is indivisible`, failedTransactionId: transactions[0]?.id };
635
+ if (kind !== "out-of-gas") return { ...base, backend: "unavailable", confidence: "provisional", executionProof: "unavailable", admitted: false, steps: measured, failureCategory: kind === "source" ? "source" : "opaque", reason: `Legal-envelope validated replay unavailable after buffered execution refusal: ${lifecycleFailureReason(failure)}` };
636
+ return { ...base, backend, admitted: false, steps: measured, reason: `Validated buffered replay failed (${validatedReplayError}) and execution at the exact gas ceiling still reverted; final mint/lock/all-buys/public-opening is indivisible` };
404
637
  }
405
- }
406
- if (finalFailure !== undefined) {
638
+ } else if (finalFailure !== undefined) {
407
639
  const failedIndex = verifiedSteps.findIndex((step) => !step.success);
408
640
  const bufferedLimit = gasLimits[failedIndex];
409
641
  const failedTransaction = transactions[failedIndex];
410
- if (bufferedLimit !== undefined && failedTransaction !== undefined && looksGasExhausted(finalFailure, bufferedLimit) && (discoveryGas[failedIndex] ?? 0n) > bufferedLimit) {
642
+ if (bufferedLimit !== undefined && failedTransaction !== undefined && (nextEnvelope === undefined || failedIndex !== 0) &&
643
+ looksGasExhausted(finalFailure, bufferedLimit) && (envelopeDiscoveryGas[failedIndex] ?? 0n) > bufferedLimit) {
411
644
  // Buffered replay returned an exhaustion-shaped failed step; learn the true
412
645
  // validated envelope with one explicit replay at the exact execution ceiling.
646
+ assertCurrent();
413
647
  try {
414
- const ceilingLimits = discoveryGas;
648
+ const ceilingLimits = envelopeDiscoveryGas;
415
649
  const retried = backend === "eth_simulateV1"
416
- ? await simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true)
650
+ ? await lifecycleStage(client, "simulation.envelope", () => simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true))
417
651
  : options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
418
- if (retried !== undefined) {
419
- const retryFailure = retried.find((step) => !step.success);
420
- if (retryFailure === undefined) {
421
- admittedGasLimits = ceilingLimits;
422
- verifiedSteps = retried;
423
- finalFailure = undefined;
424
- } else {
425
- const indivisible = failedTransaction.kind === "activate" || failedTransaction.kind === "atomic";
426
- await assertLifecycleBlock(client, block, planned.chainId);
427
- return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling still failed: ${retryFailure.error ?? "the true gas envelope exceeds the current execution limit"}${indivisible ? "; final mint/lock/all-buys/public-opening is indivisible and will not be partitioned" : ""}`, failedTransactionId: failedTransaction.id };
428
- }
652
+ if (retried === undefined) {
653
+ await assertLifecycleBlock(client, block, planned.chainId);
654
+ return { ...base, backend, admitted: false, steps: measured, reason: "Validated execution at the exact current gas ceiling omitted transaction results" };
655
+ }
656
+ const retryFailureIndex = retried.findIndex((step) => !step.success);
657
+ const retryFailure = retried[retryFailureIndex];
658
+ admittedGasLimits = ceilingLimits;
659
+ verifiedSteps = retried;
660
+ finalFailure = retryFailure;
661
+ if (retryFailure !== undefined) {
662
+ const kind = transactions[retryFailureIndex]?.kind;
663
+ const indivisible = kind === "activate" || kind === "atomic";
664
+ finalFailureReason = `Validated execution at the exact current gas ceiling still failed: ${retryFailure.error ?? "Execution reverted"}${indivisible ? "; final mint/lock/all-buys/public-opening is indivisible and will not be partitioned" : ""}`;
665
+ }
666
+ } catch (failure) {
667
+ const kind = simulationFailureKind(failure);
668
+ if (kind === "abort") throw failure;
669
+ assertCurrent();
670
+ if (kind !== "out-of-gas") {
671
+ await assertLifecycleBlock(client, block, planned.chainId);
672
+ return { ...base, backend: "unavailable", confidence: "provisional", executionProof: "unavailable", admitted: false, steps: measured, reason: `Legal-envelope validated replay unavailable: ${lifecycleFailureReason(failure)}` };
429
673
  }
430
- } catch { /* Envelope discovery unavailable; report the original buffered-limit failure unchanged. */ }
674
+ await assertLifecycleBlock(client, block, planned.chainId);
675
+ return { ...base, backend, admitted: false, steps: measured, reason: `Validated buffered replay failed (${finalFailure?.error ?? "Execution reverted"}) and execution at the exact gas ceiling still reverted: ${lifecycleFailureReason(failure)}` };
676
+ }
677
+ }
678
+ }
679
+ assertCurrent();
680
+ if (nextEnvelope !== undefined && finalFailure === undefined) {
681
+ const currentMinimum = buffer(verifiedSteps[0]?.gasRequired ?? verifiedSteps[0]?.gasUsed ?? nextEnvelope.gas);
682
+ if (nextEnvelope.gas < currentMinimum) {
683
+ await assertLifecycleBlock(client, block, planned.chainId);
684
+ return { ...base, backend, admitted: false, executionProof: "proved", protocolFit: "failed", steps: verifiedSteps,
685
+ failureCategory: "capacity", capacityConstraint: "gas-envelope", failedTransactionId: transactions[0]?.id,
686
+ reason: "Exact reviewed replay leaves insufficient current minimum gas headroom" };
431
687
  }
432
688
  }
433
689
  const estimates: LifecycleSimulationStep[] = [];
434
- const balance = rpcQuantity(await lifecyclePinnedRpc(client, "eth_getBalance", [planned.account, toHex(block.number)], block), "creator native balance");
435
- let remainingBalance = balance;
690
+ // A definitive returned replay failure already refuses execution. Retain its
691
+ // fee observations without inventing a balance or doing redundant payer work.
692
+ let remainingBalance = finalFailure === undefined
693
+ ? rpcQuantity(await lifecyclePinnedRpc(client, "eth_getBalance", [planned.account, toHex(block.number)], block), "creator native balance")
694
+ : undefined;
436
695
  let affordabilityFailure: string | undefined;
437
696
  const feeContext = configuration.estimateDataFee === undefined || sourceClient === client ? context : { ...context, client: sourceClient };
438
697
  for (let index = 0; index < verifiedSteps.length; index += 1) {
439
698
  const step = verifiedSteps[index]; const transaction = transactions[index]; const gasLimit = admittedGasLimits[index];
440
699
  if (step === undefined || transaction === undefined || gasLimit === undefined) throw new LifecyclePlanningError("INVALID_SIMULATION", "Missing verified fee context");
441
- const dataFee = limits.protocol === "nitro" ? 0n : configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit }, feeContext);
700
+ const dataFee = limits.protocol === "nitro" ? 0n : configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit, gasPrice }, feeContext);
442
701
  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");
443
702
  // Nitro gas includes poster charges. Never add the quoted poster fee again.
444
703
  const executionFee = gasLimit * gasPrice;
445
704
  const required = transaction.value + executionFee + (dataFee ?? 0n);
446
705
  if (required >= 1n << 256n) throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Transaction value and fee envelope exceed uint256");
447
- if (required > remainingBalance && affordabilityFailure === undefined) affordabilityFailure = "Creator native balance cannot fund the remaining transaction value and execution/data fee envelopes";
448
- remainingBalance -= required;
706
+ if (remainingBalance !== undefined) {
707
+ if (required > remainingBalance && affordabilityFailure === undefined) affordabilityFailure = "Creator native balance cannot fund the remaining transaction value and execution/data fee envelopes";
708
+ remainingBalance -= required;
709
+ }
449
710
  const poster = posterGas[index];
450
711
  estimates.push({ ...step, estimate: { gasUsed: step.gasUsed ?? 0n, gasLimit, gasPrice, executionFee, dataFee, posterGas: poster, posterFee: poster === undefined ? undefined : poster * gasPrice, dataFeeIncludedInGas: limits.protocol === "nitro", totalFee: dataFee === undefined ? undefined : executionFee + dataFee, feeConfidence: dataFee === undefined ? "execution-only" : "execution-and-data" } });
451
712
  }
452
713
  await assertLifecycleBlock(client, block, planned.chainId);
453
- return { ...base, backend, executionProof: finalFailure === undefined ? "proved" : "failed", protocolFit: finalFailure === undefined ? "proved" : "unknown", admitted: finalFailure === undefined && affordabilityFailure === undefined, steps: estimates, reason: finalFailure?.error ?? affordabilityFailure, failedTransactionId: finalFailure?.transactionId };
714
+ return { ...base, backend, executionProof: finalFailure === undefined ? "proved" : "failed", protocolFit: finalFailure === undefined ? "proved" : "unknown", admitted: finalFailure === undefined && affordabilityFailure === undefined, steps: estimates, reason: finalFailureReason ?? finalFailure?.error ?? affordabilityFailure, failedTransactionId: finalFailure?.transactionId, failureCategory: finalFailure?.failureCategory ?? (affordabilityFailure === undefined ? undefined : "affordability"), capacityConstraint: finalFailure?.failureCategory === "capacity" ? "compute" : undefined };
454
715
  }
455
716