@black-market/sdk 0.10.0 → 0.11.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 (38) hide show
  1. package/README.md +4 -2
  2. package/dist/lifecycle/calibration.d.ts.map +1 -1
  3. package/dist/lifecycle/calibration.js +2 -0
  4. package/dist/lifecycle/calibration.js.map +1 -1
  5. package/dist/lifecycle/planner.d.ts.map +1 -1
  6. package/dist/lifecycle/planner.js +44 -35
  7. package/dist/lifecycle/planner.js.map +1 -1
  8. package/dist/lifecycle/presets.d.ts +5 -0
  9. package/dist/lifecycle/presets.d.ts.map +1 -1
  10. package/dist/lifecycle/presets.js +15 -1
  11. package/dist/lifecycle/presets.js.map +1 -1
  12. package/dist/lifecycle/rpc.js +3 -2
  13. package/dist/lifecycle/rpc.js.map +1 -1
  14. package/dist/lifecycle/simulation.d.ts +1 -1
  15. package/dist/lifecycle/simulation.d.ts.map +1 -1
  16. package/dist/lifecycle/simulation.js +115 -174
  17. package/dist/lifecycle/simulation.js.map +1 -1
  18. package/dist/lifecycle/transactions.d.ts +6 -0
  19. package/dist/lifecycle/transactions.d.ts.map +1 -1
  20. package/dist/lifecycle/transactions.js +39 -0
  21. package/dist/lifecycle/transactions.js.map +1 -1
  22. package/dist/lifecycle/types.d.ts +6 -3
  23. package/dist/lifecycle/types.d.ts.map +1 -1
  24. package/dist/lifecycle/types.js.map +1 -1
  25. package/docs/launch-lifecycle.md +77 -32
  26. package/examples/.env.example +4 -0
  27. package/examples/README.md +21 -2
  28. package/examples/launch-example-support.ts +1 -0
  29. package/examples/launch-example.ts +1 -0
  30. package/examples/launch.ts +1 -0
  31. package/package.json +1 -1
  32. package/src/lifecycle/calibration.ts +1 -0
  33. package/src/lifecycle/planner.ts +41 -32
  34. package/src/lifecycle/presets.ts +16 -2
  35. package/src/lifecycle/rpc.ts +3 -2
  36. package/src/lifecycle/simulation.ts +111 -165
  37. package/src/lifecycle/transactions.ts +38 -1
  38. package/src/lifecycle/types.ts +9 -4
@@ -4,6 +4,8 @@ import { hashLaunchIdentity, hashLaunchPlan, LifecyclePhase, type LaunchPlanV1,
4
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
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
6
  import { decodeAbyssLifecycleMarketConfig, decodePoolBoundV4LifecycleMarketConfig, decodeV4LifecycleMarketConfig, isPoolBoundV4ConfigVersion } from "./markets.js";
7
+ import { hmmV2ForwardingReserve } from "./presets.js";
8
+ import { transactionGasEstimate } from "./transactions.js";
7
9
 
8
10
  function committedPositionCount(plan: LaunchPlanV1): number {
9
11
  let count = 0;
@@ -111,8 +113,6 @@ async function proveNitroBackend(client: LifecycleRpcClient, planned: PlannedLau
111
113
  }
112
114
 
113
115
  async function simulateRpcPass(client: LifecycleRpcClient, planned: PlannedLaunch, block: LifecycleBlock, transactions: readonly LifecycleTransaction[], gasLimits: readonly bigint[], limits: ResolvedLifecycleLimits, gasPrice: bigint, validation: boolean): Promise<LifecycleSimulationStep[]> {
114
- const totalRequestedGas = gasLimits.reduce((total, gas) => total + gas, 0n);
115
- if (limits.maxSimulationGas !== undefined && totalRequestedGas > limits.maxSimulationGas) throw new LifecyclePlanningError("SIMULATION_RPC_CAP", "Sequential request exceeds the current RPC aggregate simulation gas cap; use an isolated controlled fork");
116
116
  // Natural child-block fees must govern fee-bearing admission; overriding
117
117
  // baseFeePerGas with the parent's would fabricate a lower envelope and admit
118
118
  // calls that the actual chain would reject. eth_simulateV1 derives the child
@@ -315,7 +315,7 @@ function observedFields<T extends object>(value: T): () => boolean {
315
315
 
316
316
  /** Internal continuation owned by one SDK read invocation. Only its positive
317
317
  * capability proof is reused; every candidate executes its entire economic sequence. */
318
- export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, block: LifecycleBlock, hasTransactions = true): PreparedLaunchSimulation {
318
+ export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, block: LifecycleBlock, hasTransactions = true, reviewedEnvelope?: NextTransactionEnvelope): PreparedLaunchSimulation {
319
319
  const { client, planned, fork } = options;
320
320
  const signal = options.signal;
321
321
  if (signal?.aborted) throw Object.assign(new Error("Lifecycle simulation cancelled"), { name: "AbortError" });
@@ -353,7 +353,7 @@ export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, bloc
353
353
  resolveLifecycleLimits(context, policy),
354
354
  readLifecycleChainId(client),
355
355
  lifecyclePinnedRpc(client, "eth_getCode", [context.account, toHex(context.block.number)], context.block),
356
- hasTransactions ? lifecycleRpc(client, "eth_gasPrice") : undefined,
356
+ hasTransactions && reviewedEnvelope === undefined ? lifecycleRpc(client, "eth_gasPrice") : undefined,
357
357
  ]));
358
358
  })().then((observations) => ({ observations }), (failure: unknown) => ({ failure }));
359
359
  // Refused inputs may discard this read-only preparation before it settles.
@@ -439,7 +439,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
439
439
  // Standalone calls always produce fresh context, including live chain and fees.
440
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");
441
441
  const block = pinnedBlock ?? await readLifecycleBlock(options.client);
442
- const prepared = prepareLaunchSimulation(options, block, transactions.length !== 0);
442
+ const prepared = prepareLaunchSimulation(options, block, transactions.length !== 0, nextEnvelope);
443
443
  try { return await prepared(transactions, nextEnvelope); }
444
444
  finally { prepared.close(); }
445
445
  }
@@ -458,7 +458,7 @@ async function simulateLaunchTransactionsWithContext(
458
458
  const sourceClient = lifecycleSourceClient(client);
459
459
  const { block } = context;
460
460
  const [{ limits, configuration }, chainId, accountCodeValue, gasPriceValue] = observations;
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 };
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, deferredTransactionIds: [] as string[] };
462
462
  if (chainId !== planned.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain changed before sequential simulation");
463
463
  const accountCode = rpcHex(accountCodeValue, "account code");
464
464
  if (accountCode !== "0x") {
@@ -473,36 +473,48 @@ async function simulateLaunchTransactionsWithContext(
473
473
  }
474
474
  }
475
475
  if (transactions.length === 0) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, executionProof: "proved", protocolFit: "proved", backend: "unavailable", admitted: true, steps: [] }; }
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.
476
+ if (nextEnvelope !== undefined && (transactions.length !== 1 || typeof nextEnvelope.gas !== "bigint" || nextEnvelope.gas <= 0n || nextEnvelope.gas >= 1n << 64n ||
477
+ typeof nextEnvelope.gasPrice !== "bigint" || nextEnvelope.gasPrice < 0n || nextEnvelope.gasPrice >= 1n << 256n)) {
478
+ throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Reviewed replay requires one immediate transaction with exact uint64 gas and uint256 price");
479
+ }
480
+ const allTransactions = transactions;
481
+ if (limits.maxSimulationGas !== undefined && nextEnvelope === undefined) {
482
+ let estimatedGas = 0n, count = 0;
483
+ for (const transaction of transactions) {
484
+ estimatedGas += transactionGasEstimate(planned.plan, transaction);
485
+ if (estimatedGas > limits.maxSimulationGas) break;
486
+ count += 1;
487
+ }
488
+ transactions = transactions.slice(0, count);
489
+ base.deferredTransactionIds = allTransactions.slice(count).map((transaction) => transaction.id);
490
+ if (count === 0) {
491
+ const computeCap = limits.transactionGasCeiling !== undefined && limits.transactionGasCeiling < limits.executionGasCeiling ? limits.transactionGasCeiling : limits.executionGasCeiling;
492
+ const exceedsCompute = estimatedGas > computeCap;
493
+ const gasInMillions = (gas: bigint) => {
494
+ const tenths = (gas + 50_000n) / 100_000n;
495
+ return `${tenths / 10n}.${tenths % 10n}`;
496
+ };
497
+ const atomic = allTransactions[0]!.kind === "atomic";
498
+ const reason = exceedsCompute
499
+ ? `${atomic ? "Atomic launch" : "Immediate transaction"} needs about ${gasInMillions(estimatedGas)}M gas, above the ${gasInMillions(computeCap)}M per-transaction limit${atomic ? "; use staged" : ""}`
500
+ : "The immediate transaction estimate exceeds the RPC aggregate simulation gas cap; no prefix can be proved";
501
+ await assertLifecycleBlock(client, block, planned.chainId);
502
+ return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], failureCategory: "capacity", capacityConstraint: exceedsCompute ? "compute" : "simulation-gas", reason };
503
+ }
504
+ }
505
+ const recommendedGasPrice = nextEnvelope === undefined ? rpcQuantity(gasPriceValue, "gas price") : nextEnvelope.gasPrice;
506
+ // New recommendations cover lagging quotes; reviewed fees are replayed unchanged
507
+ // under natural child fees, never refreshed or silently increased.
481
508
  const baseFeeEnvelope = (block.baseFeePerGas ?? 0n) * 2n;
482
509
  const gasPrice = nextEnvelope?.gasPrice ?? (recommendedGasPrice > baseFeeEnvelope ? recommendedGasPrice : baseFeeEnvelope);
483
510
  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
- }
488
511
  const buffer = (gas: bigint) => (gas * BigInt(10000 + limits.headroomBps) + 9999n) / 10000n;
489
- const computeCeiling = limits.transactionGasCeiling !== undefined && limits.transactionGasCeiling < limits.executionGasCeiling ? limits.transactionGasCeiling : limits.executionGasCeiling;
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
- }
498
512
  const posterGas: bigint[] = [];
499
513
  if (limits.protocol === "nitro") {
500
- // Lightweight poster reads may overlap the isolated capability request.
501
- // Economic work is not even queued until both prerequisites have succeeded.
502
514
  try {
503
515
  const [, estimates] = await Promise.all([
504
516
  proveBackend(limits, gasPrice),
505
- lifecycleStage(client, "simulation.poster", () => Promise.all(transactions.map(async (transaction) => buffer((await readNitroPosterGas(transaction, context)).posterGas)))),
517
+ nextEnvelope === undefined ? lifecycleStage(client, "simulation.poster", () => Promise.all(transactions.map(async (transaction) => buffer((await readNitroPosterGas(transaction, context)).posterGas)))) : [],
506
518
  ]);
507
519
  posterGas.push(...estimates);
508
520
  } catch (failure) {
@@ -513,176 +525,110 @@ async function simulateLaunchTransactionsWithContext(
513
525
  }
514
526
  }
515
527
  assertCurrent();
516
- // The poster quote allocates gas only. The native exact replay enforces actual
517
- // compute hold; subtracting this approximate budget from gasUsed would not.
518
528
  const discoveryGas = transactions.map((_, index) => {
519
529
  const total = limits.executionGasCeiling + (posterGas[index] ?? 0n);
520
530
  const capped = limits.transactionGasCeiling !== undefined && limits.transactionGasCeiling < total ? limits.transactionGasCeiling : total;
521
531
  if (capped <= 0n || capped >= 1n << 64n) throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Compute/poster envelope exceeds uint64 gas");
522
532
  return capped;
523
533
  });
524
- let backend: "eth_simulateV1" | "controlled-fork" = "eth_simulateV1";
525
- let measured: LifecycleSimulationStep[];
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.
528
- try {
529
- measured = await lifecycleStage(client, "simulation.measure", () => simulateRpcPass(client, planned, block, transactions, discoveryGas, limits, gasPrice, false));
530
- }
531
- catch (failure) {
532
- if (simulationFailureKind(failure) === "abort") throw failure;
534
+ let backend: "eth_simulateV1" | "controlled-fork" = nextEnvelope !== undefined && limits.protocol === "evm" && options.fork !== undefined ? "controlled-fork" : "eth_simulateV1";
535
+ let measured: LifecycleSimulationStep[] = [];
536
+ if (nextEnvelope === undefined) {
537
+ try {
538
+ measured = await lifecycleStage(client, "simulation.measure", () => simulateRpcPass(client, planned, block, transactions, discoveryGas, limits, gasPrice, false));
539
+ } catch (failure) {
540
+ if (simulationFailureKind(failure) === "abort") throw failure;
541
+ assertCurrent();
542
+ if (options.fork === undefined || limits.protocol === "nitro") {
543
+ await assertLifecycleBlock(client, block, planned.chainId);
544
+ 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."}` };
545
+ }
546
+ backend = "controlled-fork";
547
+ measured = await simulateForkPass(options.fork, planned, block, transactions, discoveryGas, gasPrice, true, limits.headroomBps);
548
+ }
533
549
  assertCurrent();
534
- if (options.fork === undefined || limits.protocol === "nitro") {
550
+ const failedIndex = measured.findIndex((step) => !step.success);
551
+ if (failedIndex >= 0 && limits.maxSimulationGas !== undefined &&
552
+ measured.reduce((gas, step) => gas + (step.gasUsed ?? 0n), 0n) * 100n >= limits.maxSimulationGas * 95n) {
553
+ // The provider exhausted its aggregate used-gas budget, not the contract.
554
+ // Preserve only actual successful results; the failed call is deferred too.
555
+ measured = measured.slice(0, failedIndex);
556
+ transactions = transactions.slice(0, failedIndex);
557
+ base.deferredTransactionIds = allTransactions.slice(failedIndex).map((transaction) => transaction.id);
558
+ if (transactions.length === 0) {
559
+ await assertLifecycleBlock(client, block, planned.chainId);
560
+ return { ...base, confidence: "provisional", executionProof: "unavailable", backend, admitted: false, steps: [], failureCategory: "source", reason: "RPC aggregate simulation gas cap left no successful prefix to prove" };
561
+ }
562
+ }
563
+ const failed = measured.find((step) => !step.success);
564
+ 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 }; }
565
+ if (diagnostic) {
535
566
  await assertLifecycleBlock(client, block, planned.chainId);
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."}` };
567
+ return { ...base, backend, admitted: false, executionProof: "unavailable", steps: measured };
537
568
  }
538
- backend = "controlled-fork";
539
- measured = await simulateForkPass(options.fork, planned, block, transactions, discoveryGas, gasPrice, true, limits.headroomBps);
540
- }
541
- assertCurrent();
542
- const failed = measured.find((step) => !step.success);
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
569
  }
548
- // Padding belongs to new envelopes. A reviewed envelope may consume its
549
- // original margin; only exact replay at its held gas/price proves it still fits.
550
- const computeGas = measured.map((step, index) => {
551
- const required = step.gasRequired ?? step.gasUsed ?? computeCeiling;
552
- return nextEnvelope !== undefined && index === 0 ? required : buffer(required);
553
- });
554
- const gasLimits = computeGas.map((gas, index) =>
555
- nextEnvelope !== undefined && index === 0 ? nextEnvelope.gas : gas + (posterGas[index] ?? 0n));
556
- const exceeds = gasLimits.findIndex((gas, index) => gas <= 0n || gas >= 1n << 64n || (computeGas[index] ?? 0n) > limits.executionGasCeiling || limits.transactionGasCeiling !== undefined && gas > limits.transactionGasCeiling);
570
+ const gasLimits = nextEnvelope === undefined ? measured.map((step, index) => {
571
+ const gross = step.gasRequired ?? step.gasUsed!;
572
+ const reserve = transactions[index]?.kind === "activate" || transactions[index]?.kind === "atomic" ? hmmV2ForwardingReserve(planned.plan) : 0n;
573
+ const desired = gross + reserve > buffer(gross) ? gross + reserve : buffer(gross);
574
+ const available = discoveryGas[index]! - (posterGas[index] ?? 0n);
575
+ return (desired < available ? desired : available) + (posterGas[index] ?? 0n);
576
+ }) : [nextEnvelope.gas];
577
+ const exceeds = gasLimits.findIndex((gas, index) =>
578
+ gas <= 0n || gas >= 1n << 64n || limits.transactionGasCeiling !== undefined && gas > limits.transactionGasCeiling ||
579
+ nextEnvelope === undefined && (measured[index]!.gasRequired ?? measured[index]!.gasUsed!) > gas - (posterGas[index] ?? 0n) ||
580
+ limits.protocol === "evm" && gas > limits.executionGasCeiling);
557
581
  if (exceeds >= 0) {
558
582
  await assertLifecycleBlock(client, block, planned.chainId);
559
- 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 };
583
+ return { ...base, executionProof: "unavailable", protocolFit: "failed", backend, admitted: false, steps: measured, failureCategory: "capacity", capacityConstraint: "gas-envelope", reason: "Measured execution or reviewed gas exceeds the current compute/complete transaction envelope", failedTransactionId: transactions[exceeds]?.id };
560
584
  }
561
- // Envelope discovery may enlarge future unfinished steps, never the reviewed
562
- // immediate one. Every retry still executes the entire sequence at held price.
563
- const envelopeDiscoveryGas = nextEnvelope === undefined ? discoveryGas : discoveryGas.map((gas, index) => index === 0 ? nextEnvelope.gas : gas);
564
- // Replay the complete sequence from its original state at the exact buffered
565
- // limits. Only explicit native OOG can discover one larger legal envelope;
566
- // semantic, opaque and transport failures cannot authorize a capacity retry.
567
- let verified: LifecycleSimulationStep[];
568
- let validatedReplayError: string | undefined;
569
- assertCurrent();
585
+ let verifiedSteps: LifecycleSimulationStep[] = [];
586
+ let admittedGasLimits = gasLimits;
587
+ let replayError: string | undefined;
588
+ const replay = (gas: readonly bigint[]) => backend === "eth_simulateV1"
589
+ ? simulateRpcPass(client, planned, block, transactions, gas, limits, gasPrice, true)
590
+ : simulateForkPass(options.fork!, planned, block, transactions, gas, gasPrice, false, limits.headroomBps);
570
591
  try {
571
- if (backend === "eth_simulateV1") verified = await lifecycleStage(client, "simulation.replay", () => simulateRpcPass(client, planned, block, transactions, gasLimits, limits, gasPrice, true));
572
- else {
573
- if (options.fork === undefined) throw new LifecyclePlanningError("INVALID_SIMULATION", "Controlled fork is missing for the exact-gas replay");
574
- verified = await simulateForkPass(options.fork, planned, block, transactions, gasLimits, gasPrice, false, limits.headroomBps);
575
- }
592
+ verifiedSteps = await lifecycleStage(client, "simulation.replay", () => replay(gasLimits));
576
593
  } catch (failure) {
577
594
  const kind = simulationFailureKind(failure);
578
595
  if (kind === "abort") throw failure;
579
596
  assertCurrent();
580
- const message = lifecycleFailureReason(failure);
581
- if (backend !== "eth_simulateV1") {
597
+ if (kind !== "out-of-gas" || backend !== "eth_simulateV1" || nextEnvelope !== undefined) {
582
598
  await assertLifecycleBlock(client, block, planned.chainId);
583
- return { ...base, backend, admitted: false, steps: [], reason: `Exact-gas validated replay failed: ${message}` };
599
+ return { ...base, backend: "unavailable", confidence: "provisional", executionProof: "unavailable", admitted: false, steps: measured, failureCategory: kind === "source" ? "source" : "opaque", reason: `Exact-gas validated replay unavailable: ${lifecycleFailureReason(failure)}` };
584
600
  }
585
- if (kind !== "out-of-gas") {
586
- await assertLifecycleBlock(client, block, planned.chainId);
587
- return { ...base, backend: "unavailable", confidence: "provisional", executionProof: "unavailable", admitted: false, steps: measured, failureCategory: kind === "source" ? "source" : "opaque", reason: `Exact-gas validated replay unavailable: ${message}` };
588
- }
589
- validatedReplayError = message;
590
- verified = [];
601
+ replayError = lifecycleFailureReason(failure);
591
602
  }
592
603
  assertCurrent();
593
- let verifiedSteps = verified;
594
- let admittedGasLimits = gasLimits;
595
604
  let finalFailure = verifiedSteps.find((step) => !step.success);
596
- let finalFailureReason: string | undefined;
597
- if (finalFailure === undefined && validatedReplayError !== undefined) {
598
- if (!envelopeDiscoveryGas.some((gas, index) => gas > (gasLimits[index] ?? gas))) {
599
- await assertLifecycleBlock(client, block, planned.chainId);
600
- return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling failed: ${validatedReplayError}` };
601
- }
602
- // The buffered validated replay threw before yielding steps. Attempt one
603
- // explicit envelope-discovery replay at the exact execution ceiling on the
604
- // same pinned block; headroom is never silently raised — the carried gas is
605
- // the validated envelope itself.
605
+ const failedIndex = verifiedSteps.findIndex((step) => !step.success);
606
+ const rescue = nextEnvelope === undefined && backend === "eth_simulateV1" &&
607
+ (replayError !== undefined && discoveryGas.some((gas, index) => index < transactions.length && gas > gasLimits[index]!) ||
608
+ finalFailure !== undefined && looksGasExhausted(finalFailure, gasLimits[failedIndex]!) && discoveryGas[failedIndex]! > gasLimits[failedIndex]!);
609
+ // One legal-envelope rescue covers thrown/returned genuine OOG. Never retry
610
+ // semantic errors, switch replay backends, or enlarge a held immediate envelope.
611
+ if (rescue) {
606
612
  assertCurrent();
607
613
  try {
608
- const ceilingLimits = envelopeDiscoveryGas;
609
- const retried = backend === "eth_simulateV1"
610
- ? await lifecycleStage(client, "simulation.envelope", () => simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true))
611
- : options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
612
- const retryFailureIndex = retried?.findIndex((step) => !step.success) ?? -1;
613
- const retryFailure = retried?.[retryFailureIndex];
614
- if (retried === undefined) {
615
- await assertLifecycleBlock(client, block, planned.chainId);
616
- return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling still failed: ${validatedReplayError}` };
617
- }
618
- admittedGasLimits = ceilingLimits;
619
- verifiedSteps = retried;
620
- finalFailure = retryFailure;
621
- validatedReplayError = undefined;
622
- if (retryFailure !== undefined) {
623
- const kind = transactions[retryFailureIndex]?.kind;
624
- const indivisible = kind === "activate" || kind === "atomic";
625
- 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" : ""}`;
626
- }
614
+ admittedGasLimits = discoveryGas.slice(0, transactions.length);
615
+ verifiedSteps = await lifecycleStage(client, "simulation.envelope", () => replay(admittedGasLimits));
616
+ finalFailure = verifiedSteps.find((step) => !step.success);
617
+ replayError = undefined;
627
618
  } catch (failure) {
628
619
  const kind = simulationFailureKind(failure);
629
620
  if (kind === "abort") throw failure;
630
621
  assertCurrent();
631
622
  await assertLifecycleBlock(client, block, planned.chainId);
632
- 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)}` };
633
- 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` };
634
- }
635
- } else if (finalFailure !== undefined) {
636
- const failedIndex = verifiedSteps.findIndex((step) => !step.success);
637
- const bufferedLimit = gasLimits[failedIndex];
638
- const failedTransaction = transactions[failedIndex];
639
- if (bufferedLimit !== undefined && failedTransaction !== undefined && (nextEnvelope === undefined || failedIndex !== 0) &&
640
- looksGasExhausted(finalFailure, bufferedLimit) && (envelopeDiscoveryGas[failedIndex] ?? 0n) > bufferedLimit) {
641
- // Buffered replay returned an exhaustion-shaped failed step; learn the true
642
- // validated envelope with one explicit replay at the exact execution ceiling.
643
- assertCurrent();
644
- try {
645
- const ceilingLimits = envelopeDiscoveryGas;
646
- const retried = backend === "eth_simulateV1"
647
- ? await lifecycleStage(client, "simulation.envelope", () => simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true))
648
- : options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
649
- if (retried === undefined) {
650
- await assertLifecycleBlock(client, block, planned.chainId);
651
- return { ...base, backend, admitted: false, steps: measured, reason: "Validated execution at the exact current gas ceiling omitted transaction results" };
652
- }
653
- const retryFailureIndex = retried.findIndex((step) => !step.success);
654
- const retryFailure = retried[retryFailureIndex];
655
- admittedGasLimits = ceilingLimits;
656
- verifiedSteps = retried;
657
- finalFailure = retryFailure;
658
- if (retryFailure !== undefined) {
659
- const kind = transactions[retryFailureIndex]?.kind;
660
- const indivisible = kind === "activate" || kind === "atomic";
661
- 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" : ""}`;
662
- }
663
- } catch (failure) {
664
- const kind = simulationFailureKind(failure);
665
- if (kind === "abort") throw failure;
666
- assertCurrent();
667
- if (kind !== "out-of-gas") {
668
- await assertLifecycleBlock(client, block, planned.chainId);
669
- return { ...base, backend: "unavailable", confidence: "provisional", executionProof: "unavailable", admitted: false, steps: measured, reason: `Legal-envelope validated replay unavailable: ${lifecycleFailureReason(failure)}` };
670
- }
671
- await assertLifecycleBlock(client, block, planned.chainId);
672
- 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)}` };
673
- }
623
+ 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: ${lifecycleFailureReason(failure)}` };
624
+ return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling still failed: ${lifecycleFailureReason(failure)}` };
674
625
  }
675
626
  }
676
- assertCurrent();
677
- if (nextEnvelope !== undefined && finalFailure === undefined) {
678
- const currentMinimum = verifiedSteps[0]?.gasRequired ?? verifiedSteps[0]?.gasUsed ?? nextEnvelope.gas;
679
- if (nextEnvelope.gas < currentMinimum) {
680
- await assertLifecycleBlock(client, block, planned.chainId);
681
- return { ...base, backend, admitted: false, executionProof: "proved", protocolFit: "failed", steps: verifiedSteps,
682
- failureCategory: "capacity", capacityConstraint: "gas-envelope", failedTransactionId: transactions[0]?.id,
683
- reason: "Exact reviewed replay exceeds the held gas envelope" };
684
- }
627
+ if (replayError !== undefined) {
628
+ await assertLifecycleBlock(client, block, planned.chainId);
629
+ return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling failed: ${replayError}` };
685
630
  }
631
+ assertCurrent();
686
632
  const estimates: LifecycleSimulationStep[] = [];
687
633
  // A definitive returned replay failure already refuses execution. Retain its
688
634
  // fee observations without inventing a balance or doing redundant payer work.
@@ -708,6 +654,6 @@ async function simulateLaunchTransactionsWithContext(
708
654
  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" } });
709
655
  }
710
656
  await assertLifecycleBlock(client, block, planned.chainId);
711
- 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 };
657
+ 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, failureCategory: finalFailure?.failureCategory ?? (affordabilityFailure === undefined ? undefined : "affordability"), capacityConstraint: finalFailure?.failureCategory === "capacity" ? "compute" : undefined };
712
658
  }
713
659
 
@@ -1,8 +1,45 @@
1
1
  import { encodeFunctionData } from "viem";
2
2
  import { launchLifecycleAbi } from "./abi.js";
3
- import { LifecycleFundingKind, LifecycleMode } from "./schema.js";
3
+ import { LifecycleFundingKind, LifecycleMode, type LaunchPlanV1 } from "./schema.js";
4
4
  import { LifecyclePlanningError, type BuildLaunchTransactionsOptions, type LifecycleTransaction } from "./types.js";
5
5
  import { assertLifecycleTokenAllocation } from "./allocations.js";
6
+ import { HMM_V2_PREPARE_GAS, hmmV2ForwardingReserve } from "./presets.js";
7
+ import { isHmmProfile } from "./hmm.js";
8
+
9
+ const OTHER_MARKET_PREPARE_GAS = 16_000_000n;
10
+ const BEGIN_GAS = 9_500_000n;
11
+ const ACTIVATE_MARKET_GAS = 6_000_000n;
12
+
13
+ export function preparationGasEstimate(plan: LaunchPlanV1, first: number, count: number): bigint {
14
+ let gas = 0n;
15
+ for (let index = first; index < first + count; index += 1) gas += isHmmProfile(plan.markets[index]!.profileId) ? HMM_V2_PREPARE_GAS : OTHER_MARKET_PREPARE_GAS;
16
+ return gas;
17
+ }
18
+
19
+ /** Contiguous cost packing preserves committed market order and recovery progress. */
20
+ export function packPreparationBatches(plan: LaunchPlanV1, prepared: number, computeCap: bigint): number[] {
21
+ const batches = prepared > 0 ? [prepared] : [];
22
+ let count = 0, gas = 0n;
23
+ for (let index = prepared; index < plan.markets.length; index += 1) {
24
+ const estimate = preparationGasEstimate(plan, index, 1);
25
+ if (count > 0 && gas + estimate > computeCap) { batches.push(count); count = 0; gas = 0n; }
26
+ count += 1;
27
+ gas += estimate;
28
+ }
29
+ if (count > 0) batches.push(count);
30
+ return batches;
31
+ }
32
+
33
+ /** Estimates select RPC proof coverage only; measurement and exact replay size gas. */
34
+ export function transactionGasEstimate(plan: LaunchPlanV1, transaction: LifecycleTransaction): bigint {
35
+ switch (transaction.kind) {
36
+ case "begin": return BEGIN_GAS;
37
+ case "prepare": return preparationGasEstimate(plan, transaction.marketStart!, transaction.marketCount!);
38
+ case "activate": return ACTIVATE_MARKET_GAS * BigInt(plan.markets.length) + hmmV2ForwardingReserve(plan);
39
+ case "atomic": return BEGIN_GAS + preparationGasEstimate(plan, 0, plan.markets.length) + ACTIVATE_MARKET_GAS * BigInt(plan.markets.length) + hmmV2ForwardingReserve(plan);
40
+ default: return 100_000n;
41
+ }
42
+ }
6
43
 
7
44
  /** Pure unsigned launch commands. Every command carries the complete commitment;
8
45
  * preparation splits only empty markets, never positions, buys or public opening.
@@ -44,7 +44,9 @@ export type LifecycleLimitContext = { client: LifecycleRpcClient; account: Addre
44
44
  export type LifecycleLimits = {
45
45
  /** Optional tightening caps on the complete transaction gas envelope, including Nitro poster gas. */
46
46
  headroomBps?: number; chainGasLimit?: bigint; rpcGasLimit?: bigint; accountGasLimit?: bigint;
47
- maxCalldataBytes?: number; maxSimulationGas?: bigint;
47
+ maxCalldataBytes?: number;
48
+ /** Aggregate gas-used budget for one simulation request; defaults to 50M on chain 4663. */
49
+ maxSimulationGas?: bigint;
48
50
  chainId?: bigint; orchestrator?: Address; account?: Address; observedBlockNumber?: bigint; observedBlockHash?: Hash;
49
51
  /** External data fee not already charged in gas, called with exact calldata and the pinned block.
50
52
  * Nitro poster fees are included in gas and use the native NodeInterface instead. */
@@ -112,9 +114,12 @@ export type LifecycleSimulationStep = {
112
114
  };
113
115
  export type LifecycleSimulation = LifecycleProofOutcomes & {
114
116
  backend: "eth_simulateV1" | "controlled-fork" | "unavailable";
117
+ /** Admission/proved outcomes cover the successful prefix, not the deferred suffix. */
115
118
  confidence: "stateful" | "provisional"; admitted: boolean;
116
119
  blockNumber: bigint; blockHash: Hash; account: Address; chainId: bigint;
117
120
  steps: readonly LifecycleSimulationStep[]; limits: ResolvedLifecycleLimits;
121
+ /** Unproved suffix. These transactions receive no gas, price, estimate or admission. */
122
+ deferredTransactionIds: readonly string[];
118
123
  reason?: string; failedTransactionId?: string;
119
124
  failureCategory?: LifecycleFailureCategory; capacityConstraint?: LifecycleCapacityConstraint;
120
125
  };
@@ -163,7 +168,7 @@ export type PlannedLaunch = {
163
168
  /** Exact CREATE2 metadata captured from the final committed bound markets. */
164
169
  hookDeployments: readonly LifecyclePoolBoundHookDeployment[];
165
170
  progress: CanonicalLaunchProgress;
166
- preparationBatchSize: number; limits?: LifecycleLimitSource;
171
+ limits?: LifecycleLimitSource;
167
172
  /** Unstarted-launch admission choices only; never part of the committed plan or calldata. */
168
173
  allowTopologyGaps?: boolean; allowMissingPermanentTail?: boolean;
169
174
  };
@@ -204,8 +209,8 @@ export type BuildNextTransactionOptions = ReadLaunchProgressOptions & {
204
209
  planned: PlannedLaunch; action?: "continue" | "cancel"; limits?: LifecycleLimitSource; fork?: ControlledLifecycleFork;
205
210
  /** Optional active submission RPC. Only the immediate next exact transaction is estimated, read-only. */
206
211
  submissionClient?: LifecycleRpcClient;
207
- /** Untrusted reviewed next envelope. Fresh canonical semantics and complete
208
- * execution/headroom/affordability proof must accept its exact gas and gasPrice. */
212
+ /** Untrusted reviewed next envelope. Canonical identity and direct exact replay
213
+ * prove only this immediate transaction at its held gas and gasPrice. */
209
214
  reviewedTransaction?: LifecycleTransaction;
210
215
  };
211
216
  export class LifecyclePlanningError extends Error {