@black-market/sdk 0.12.0 → 0.13.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.
@@ -1,7 +1,12 @@
1
- import { decodeFunctionResult, encodeFunctionData, isAddress, isHex, parseAbi, toHex, type Abi, type Address, type Hex } from "viem";
1
+ import { decodeFunctionResult, encodeFunctionData, isAddress, isHex, keccak256, multicall3Abi, parseAbi, toHex, type Abi, type Address, type Hex } from "viem";
2
2
  import { LifecyclePlanningError, type LifecycleBlock, type LifecycleDiagnosticEvent, type LifecycleDiagnosticListener, type LifecycleDiagnosticStage, type LifecycleLimitContext, type LifecycleLimitSource, type LifecycleLimits, type LifecycleRpcClient, type LifecycleSessionCache, type LifecycleTransaction, type ResolvedLifecycleLimits } from "./types.js";
3
3
  import { lifecycleRegistryAbi } from "./abi.js";
4
4
  import type { ProfileTopologyV1 } from "./schema.js";
5
+ import { robinhoodMainnet } from "../addresses.js";
6
+
7
+ // Reviewed chain4663 deployment, independently observed at block 98,136,655.
8
+ const multicall3CodeHash = "0xd5c15df687b16f2ff992fc8d767b4216323184a2bbc6ee2f9c398c318e770891";
9
+ type IndependentRead = { params: readonly unknown[]; resolve: (value: unknown) => void; reject: (failure: unknown) => void };
5
10
 
6
11
  const lifecycleReadConcurrency = 8;
7
12
  const diagnosticRpcMethods: Record<string, true> = {
@@ -67,6 +72,11 @@ class InvocationLifecycleClient implements LifecycleRpcClient {
67
72
  private readonly concurrency: number;
68
73
  private diagnosticSequence = 0;
69
74
  private chainId?: Promise<bigint>;
75
+ private gasPrice?: Promise<unknown>;
76
+ private readonly immutableReads = new Map<string, Promise<unknown>>();
77
+ private readonly canonicalPins = new Map<string, { block: Pick<LifecycleBlock, "number" | "hash">; chainId?: bigint }>();
78
+ private readonly independentReads = new Map<string, { block: LifecycleBlock; reads: IndependentRead[] }>();
79
+ readonly finalChecks = new Set<() => void>();
70
80
  private readonly limitObservations = new Map<LifecycleLimitSource | undefined, Map<string, Promise<LifecycleLimitObservation>>>();
71
81
  private readonly session?: SessionLifecycleCache;
72
82
  private sessionGroup?: SessionReadGroup;
@@ -145,6 +155,103 @@ class InvocationLifecycleClient implements LifecycleRpcClient {
145
155
  return this.chainId ??= this.run("eth_chainId", []).then((value) => rpcQuantity(value, "chain ID"));
146
156
  }
147
157
 
158
+ observeGasPrice(): Promise<unknown> {
159
+ this.assertOpen();
160
+ return this.gasPrice ??= this.run("eth_gasPrice", []);
161
+ }
162
+
163
+ immutableRead(key: string, work: () => Promise<unknown>): Promise<unknown> {
164
+ this.assertOpen();
165
+ let pending = this.immutableReads.get(key);
166
+ if (pending === undefined) {
167
+ pending = work();
168
+ this.immutableReads.set(key, pending);
169
+ const owned = pending;
170
+ void pending.catch(() => { if (this.immutableReads.get(key) === owned) this.immutableReads.delete(key); });
171
+ }
172
+ return pending.then((value) => { this.assertOpen(); return value; });
173
+ }
174
+
175
+ deferCanonicalCheck(block: Pick<LifecycleBlock, "number" | "hash">, chainId?: bigint): void {
176
+ this.assertOpen();
177
+ const key = block.number.toString();
178
+ const existing = this.canonicalPins.get(key);
179
+ if (existing !== undefined && (existing.block.hash.toLowerCase() !== block.hash.toLowerCase() ||
180
+ existing.chainId !== undefined && chainId !== undefined && existing.chainId !== chainId)) {
181
+ this.invalidateSession();
182
+ throw new LifecyclePlanningError("STATE_REORGED", "One lifecycle invocation observed incompatible canonical pins");
183
+ }
184
+ this.canonicalPins.set(key, { block: { ...block }, chainId: chainId ?? existing?.chainId });
185
+ }
186
+
187
+ async finalize(): Promise<void> {
188
+ this.assertOpen();
189
+ const pins = [...this.canonicalPins.values()];
190
+ if (pins.length === 0) return;
191
+ const [chainId, current] = await Promise.all([
192
+ pins.some((pin) => pin.chainId !== undefined) ? this.run("eth_chainId", []).then((value) => rpcQuantity(value, "chain ID")) : undefined,
193
+ Promise.all(pins.map((pin) => readLifecycleBlock(this, toHex(pin.block.number)))),
194
+ ]);
195
+ for (let index = 0; index < pins.length; index += 1) {
196
+ const pin = pins[index]!;
197
+ if (pin.chainId !== undefined && chainId !== pin.chainId) {
198
+ this.invalidateSession();
199
+ throw new LifecyclePlanningError("CHAIN_MISMATCH", "Source RPC chain changed during the pinned operation");
200
+ }
201
+ if (current[index]!.hash.toLowerCase() !== pin.block.hash.toLowerCase()) {
202
+ this.invalidateSession();
203
+ throw new LifecyclePlanningError("STATE_REORGED", "Pinned launch state changed during the operation; read canonical progress again");
204
+ }
205
+ }
206
+ for (const check of this.finalChecks) check();
207
+ }
208
+
209
+ independentRead(params: readonly unknown[], block: LifecycleBlock, staticRead: boolean): Promise<unknown> {
210
+ return this.read("eth_call", params, block, staticRead, () => new Promise((resolve, reject) => {
211
+ const key = `${block.number}:${block.hash.toLowerCase()}`;
212
+ let group = this.independentReads.get(key);
213
+ if (group === undefined) {
214
+ group = { block, reads: [] };
215
+ this.independentReads.set(key, group);
216
+ queueMicrotask(() => {
217
+ this.independentReads.delete(key);
218
+ void this.flushIndependentReads(group!);
219
+ });
220
+ }
221
+ group.reads.push({ params, resolve, reject });
222
+ }));
223
+ }
224
+
225
+ private async flushIndependentReads(group: { block: LifecycleBlock; reads: IndependentRead[] }): Promise<void> {
226
+ const { block, reads } = group;
227
+ try {
228
+ // Two cold reads cannot beat two direct calls after code attestation.
229
+ if (reads.length < 3 || await this.observeChainId() !== 4663n) {
230
+ await Promise.all(reads.map(async (read) => {
231
+ try { read.resolve(await this.run("eth_call", read.params)); }
232
+ catch (failure) { read.reject(failure); }
233
+ }));
234
+ return;
235
+ }
236
+ const to = robinhoodMainnet.contracts.multicall3.address;
237
+ const code = rpcHex(await this.read("eth_getCode", [to, toHex(block.number)], block, true), "Multicall3 code");
238
+ if (keccak256(code) !== multicall3CodeHash) throw new LifecyclePlanningError("MULTICALL_CODE_MISMATCH", "Pinned Multicall3 runtime differs from the reviewed chain4663 deployment");
239
+ const calls = reads.map((read) => {
240
+ const request = read.params[0] as { to: Address; data: Hex };
241
+ return { target: request.to, allowFailure: true, callData: request.data };
242
+ });
243
+ const data = encodeFunctionData({ abi: multicall3Abi, functionName: "aggregate3", args: [calls] });
244
+ const raw = rpcHex(await this.run("eth_call", [{ to, data }, toHex(block.number)]), "Multicall3 result");
245
+ const results = decodeFunctionResult({ abi: multicall3Abi, functionName: "aggregate3", data: raw });
246
+ if (results.length !== reads.length) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Multicall3 omitted independent pinned results");
247
+ for (let index = 0; index < reads.length; index += 1) {
248
+ const result = results[index]!;
249
+ if (result.success) reads[index]!.resolve(result.returnData);
250
+ else reads[index]!.reject(new LifecyclePlanningError("MULTICALL_READ_FAILED", "An independent pinned getter reverted"));
251
+ }
252
+ } catch (failure) { for (const read of reads) read.reject(failure); }
253
+ }
254
+
148
255
  observeLimits(context: LifecycleLimitContext, source?: LifecycleLimitSource): Promise<LifecycleLimitObservation> {
149
256
  this.assertOpen();
150
257
  // A caller callback is live policy, not an immutable pinned observation.
@@ -168,7 +275,7 @@ class InvocationLifecycleClient implements LifecycleRpcClient {
168
275
  return observation.then((result) => { this.assertOpen(); result.assertCurrent(); return result; });
169
276
  }
170
277
 
171
- read(method: string, params: readonly unknown[], block: LifecycleBlock, staticRead = false): Promise<unknown> {
278
+ read(method: string, params: readonly unknown[], block: LifecycleBlock, staticRead = false, load?: () => Promise<unknown>): Promise<unknown> {
172
279
  this.assertOpen();
173
280
  // Source identity is this scope's original client. The complete wire call includes
174
281
  // caller, value, gas and any other call fields; no selector-only/ABI-name aliasing.
@@ -189,7 +296,7 @@ class InvocationLifecycleClient implements LifecycleRpcClient {
189
296
  pending = Promise.resolve(cached);
190
297
  } else {
191
298
  if (staticRead && this.session !== undefined) this.sessionMisses += 1;
192
- pending = this.run(method, params);
299
+ pending = load === undefined ? this.run(method, params) : load();
193
300
  if (group !== undefined) pending = pending.then((value) => {
194
301
  // Never share in-flight work or install late results after invalidation/closure.
195
302
  // Only wire hex is retained, so public decoded DTOs cannot mutate authority.
@@ -207,6 +314,11 @@ class InvocationLifecycleClient implements LifecycleRpcClient {
207
314
  this.reads.clear();
208
315
  this.chainId = undefined;
209
316
  this.limitObservations.clear();
317
+ this.gasPrice = undefined;
318
+ this.immutableReads.clear();
319
+ this.canonicalPins.clear();
320
+ this.independentReads.clear();
321
+ this.finalChecks.clear();
210
322
  const listener = this.source.onDiagnostic;
211
323
  if (this.session !== undefined && listener !== undefined) emitLifecycleDiagnostic(listener, {
212
324
  stage: "session-cache", phase: success ? "success" : "failure",
@@ -221,7 +333,15 @@ export async function withLifecycleReadClient<T>(source: LifecycleRpcClient, wor
221
333
  const client = new InvocationLifecycleClient(source);
222
334
  let success = false;
223
335
  try {
224
- const result = await work(client);
336
+ let result: T;
337
+ try { result = await work(client); }
338
+ catch (failure) {
339
+ // Refused simulations still report a changed canonical source, not a
340
+ // misleading economic refusal derived from an orphaned observation.
341
+ await client.finalize();
342
+ throw failure;
343
+ }
344
+ await client.finalize();
225
345
  success = true;
226
346
  return result;
227
347
  } finally { client.close(success); }
@@ -285,6 +405,28 @@ export function readLifecycleChainId(client: LifecycleRpcClient): Promise<bigint
285
405
  ? client.observeChainId()
286
406
  : lifecycleRpc(client, "eth_chainId").then((value) => rpcQuantity(value, "chain ID"));
287
407
  }
408
+
409
+ /** A single operation-local recommendation, never a session or cross-head quote. */
410
+ export function readLifecycleGasPrice(client: LifecycleRpcClient): Promise<unknown> {
411
+ return client instanceof InvocationLifecycleClient ? client.observeGasPrice() : lifecycleRpc(client, "eth_gasPrice");
412
+ }
413
+
414
+ /** Only the reviewed immutable factory identity may use an invocation-wide key. */
415
+ export function readLifecycleImmutable<T>(client: LifecycleRpcClient, key: string, work: () => Promise<T>): Promise<T> {
416
+ return (client instanceof InvocationLifecycleClient ? client.immutableRead(key, work) : work()) as Promise<T>;
417
+ }
418
+
419
+ /** Recheck live policy after the owner's final asynchronous canonical guards. */
420
+ export function registerLifecycleFinalCheck(client: LifecycleRpcClient, check: () => void): void {
421
+ if (client instanceof InvocationLifecycleClient) { client.assertOpen(); client.finalChecks.add(check); }
422
+ }
423
+
424
+ /** SDK-selected caller-independent getters only; never poster quotes or execution. */
425
+ function lifecycleIndependentRpc(client: LifecycleRpcClient, params: readonly unknown[], block: LifecycleBlock, staticRead: boolean): Promise<unknown> {
426
+ return client instanceof InvocationLifecycleClient
427
+ ? client.independentRead(params, block, staticRead)
428
+ : lifecyclePinnedRpc(client, "eth_call", params, block, staticRead);
429
+ }
288
430
  export function rpcObject(value: unknown, label: string): Record<string, unknown> {
289
431
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", `${label} must be an object`);
290
432
  // The object guard establishes the RPC record boundary; fields remain untrusted unknowns.
@@ -325,7 +467,11 @@ export async function readLifecycleBlock(client: LifecycleRpcClient, tag: Hex |
325
467
  if (client instanceof InvocationLifecycleClient) client.observeBlock(block, tag === "latest");
326
468
  return block;
327
469
  }
328
- export async function assertLifecycleBlock(client: LifecycleRpcClient, block: LifecycleBlock, expectedChainId?: bigint): Promise<void> {
470
+ export async function assertLifecycleBlock(client: LifecycleRpcClient, block: Pick<LifecycleBlock, "number" | "hash">, expectedChainId?: bigint): Promise<void> {
471
+ if (client instanceof InvocationLifecycleClient) {
472
+ client.deferCanonicalCheck(block, expectedChainId);
473
+ return;
474
+ }
329
475
  const [chainId, current] = await Promise.all([
330
476
  expectedChainId === undefined ? undefined : lifecycleRpc(client, "eth_chainId"),
331
477
  readLifecycleBlock(client, toHex(block.number)),
@@ -339,13 +485,15 @@ export async function assertLifecycleBlock(client: LifecycleRpcClient, block: Li
339
485
  throw new LifecyclePlanningError("STATE_REORGED", "Pinned launch state changed during the operation; read canonical progress again");
340
486
  }
341
487
  }
342
- export async function readLifecycleContract<T>(client: LifecycleRpcClient, to: Address, abi: Abi, functionName: string, args: readonly unknown[], block: LifecycleBlock, from?: Address, staticRead = false): Promise<T> {
488
+ export async function readLifecycleContract<T>(client: LifecycleRpcClient, to: Address, abi: Abi, functionName: string, args: readonly unknown[], block: LifecycleBlock, from?: Address, staticRead = false, independent = false): Promise<T> {
343
489
  const data = encodeFunctionData({ abi, functionName, args });
344
490
  const request = [{ to, from, data }, toHex(block.number)];
345
491
  const getter = abi.some((item) => item.type === "function" && item.name === functionName && (item.stateMutability === "view" || item.stateMutability === "pure"));
346
- const result = rpcHex(await (getter && functionName !== "readLaunchProgress"
347
- ? staticRead ? lifecycleStaticRpc(client, "eth_call", request, block) : lifecyclePinnedRpc(client, "eth_call", request, block)
348
- : lifecycleRpc(client, "eth_call", request)), functionName);
492
+ const result = rpcHex(await (independent
493
+ ? lifecycleIndependentRpc(client, request, block, staticRead)
494
+ : getter
495
+ ? staticRead ? lifecycleStaticRpc(client, "eth_call", request, block) : lifecyclePinnedRpc(client, "eth_call", request, block)
496
+ : lifecycleRpc(client, "eth_call", request)), functionName);
349
497
  // ABI decoding validates scalar widths and tuple layout; T names that exact frozen ABI result.
350
498
  const decoded = decodeFunctionResult({ abi, functionName, data: result }) as T;
351
499
  return decoded;
@@ -382,7 +530,7 @@ export async function readNitroPosterGas(transaction: LifecycleTransaction, cont
382
530
 
383
531
  async function readNitroScalar(context: LifecycleLimitContext, to: Address, abi: Abi, functionName: string): Promise<bigint> {
384
532
  const data = encodeFunctionData({ abi, functionName });
385
- const result = rpcHex(await lifecycleStaticRpc(context.client, "eth_call", [{ to, data }, toHex(context.block.number)], context.block), functionName);
533
+ const result = rpcHex(await lifecycleIndependentRpc(context.client, [{ to, data }, toHex(context.block.number)], context.block, true), functionName);
386
534
  if (result.length !== 66) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", `${functionName} must return exactly one ABI word`);
387
535
  return decodeFunctionResult({ abi, functionName, data: result }) as bigint;
388
536
  }
@@ -1,7 +1,7 @@
1
1
  import { decodeErrorResult, decodeEventLog, decodeFunctionResult, encodeFunctionData, encodeFunctionResult, parseAbi, toHex, type Hex } from "viem";
2
2
  import { fixedFeePoolHookV1Abi, launchLifecycleAbi, lifecycleErc20Abi } from "./abi.js";
3
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";
4
+ import { assertLifecycleBlock, assertLifecycleReadClientOpen, lifecycleFailureReason, lifecyclePinnedRpc, lifecycleRpc, lifecycleSourceClient, lifecycleStage, nitroArbSys, nitroArbSysAbi, nitroGasInfo, nitroGasInfoAbi, readLifecycleBlock, readLifecycleChainId, readLifecycleGasPrice, readNitroPosterGas, registerLifecycleFinalCheck, 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
7
  import { hmmV2ForwardingReserve } from "./presets.js";
@@ -121,7 +121,7 @@ async function simulateRpcPass(client: LifecycleRpcClient, planned: PlannedLaunc
121
121
  // parent gasLimit are set.
122
122
  const blockStateCalls = transactions.map((transaction, index) => ({
123
123
  blockOverrides: { number: toHex(block.number + BigInt(index + 1)), time: toHex(block.timestamp + BigInt(index + 1)), gasLimit: toHex(block.gasLimit) },
124
- calls: [{ from: transaction.from, to: transaction.to, data: transaction.data, value: toHex(transaction.value), gas: toHex(gasLimits[index] ?? limits.executionGasCeiling), gasPrice: toHex(gasPrice) }],
124
+ calls: [{ from: transaction.from, to: transaction.to, data: transaction.data, value: toHex(transaction.value), gas: toHex(gasLimits[index] ?? limits.executionGasCeiling), gasPrice: toHex(gasPrice), ...(transaction.nonce === undefined ? {} : { nonce: toHex(transaction.nonce) }) }],
125
125
  }));
126
126
  const result = await lifecycleRpc(client, "eth_simulateV1", [{ blockStateCalls, validation, traceTransfers: false, returnFullTransactions: false }, toHex(block.number)]);
127
127
  if (!Array.isArray(result) || result.length !== transactions.length) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Sequential simulation omitted blocks");
@@ -234,12 +234,12 @@ async function simulateForkPass(fork: ControlledLifecycleFork, planned: PlannedL
234
234
  if (transaction === undefined || gas === undefined) throw new LifecyclePlanningError("INVALID_SIMULATION", "Missing fork transaction/gas context");
235
235
  try {
236
236
  if (measure) {
237
- const estimated = rpcQuantity(await lifecycleRpc(fork.client, "eth_estimateGas", [{ from: transaction.from, to: transaction.to, data: transaction.data, value: toHex(transaction.value), gasPrice: toHex(gasPrice) }]), "fork sequential gas estimate");
237
+ const estimated = rpcQuantity(await lifecycleRpc(fork.client, "eth_estimateGas", [{ from: transaction.from, to: transaction.to, data: transaction.data, value: toHex(transaction.value), gasPrice: toHex(gasPrice), ...(transaction.nonce === undefined ? {} : { nonce: toHex(transaction.nonce) }) }]), "fork sequential gas estimate");
238
238
  gasRequired = estimated;
239
239
  const buffered = (estimated * BigInt(10000 + headroomBps) + 9999n) / 10000n;
240
240
  if (buffered < gas) gas = buffered;
241
241
  }
242
- const hash = rpcHex(await lifecycleRpc(fork.client, "eth_sendTransaction", [{ from: transaction.from, to: transaction.to, data: transaction.data, value: toHex(transaction.value), gas: toHex(gas), gasPrice: toHex(gasPrice) }]), "fork transaction hash");
242
+ const hash = rpcHex(await lifecycleRpc(fork.client, "eth_sendTransaction", [{ from: transaction.from, to: transaction.to, data: transaction.data, value: toHex(transaction.value), gas: toHex(gas), gasPrice: toHex(gasPrice), ...(transaction.nonce === undefined ? {} : { nonce: toHex(transaction.nonce) }) }]), "fork transaction hash");
243
243
  const receipt = await waitForkReceipt(fork.client, hash, fork.receiptTimeoutMs ?? 10_000);
244
244
  const gasUsed = rpcQuantity(receipt.gasUsed, "fork gas used");
245
245
  if (rpcQuantity(receipt.status, "fork transaction status") !== 1n) {
@@ -316,7 +316,7 @@ function observedFields<T extends object>(value: T): () => boolean {
316
316
 
317
317
  /** Internal continuation owned by one SDK read invocation. Only its positive
318
318
  * capability proof is reused; every candidate executes its entire economic sequence. */
319
- export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, block: LifecycleBlock, hasTransactions = true, reviewedEnvelope?: NextTransactionEnvelope): PreparedLaunchSimulation {
319
+ export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, block: LifecycleBlock, hasTransactions = true, reviewedEnvelope?: NextTransactionEnvelope, selectedGasPrice?: bigint): PreparedLaunchSimulation {
320
320
  const { client, planned, fork } = options;
321
321
  const signal = options.signal;
322
322
  if (signal?.aborted) throw Object.assign(new Error("Lifecycle simulation cancelled"), { name: "AbortError" });
@@ -355,7 +355,7 @@ export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, bloc
355
355
  resolveLifecycleLimits(context, policy),
356
356
  readLifecycleChainId(client),
357
357
  lifecyclePinnedRpc(client, "eth_getCode", [context.account, toHex(context.block.number)], context.block),
358
- hasTransactions && reviewedEnvelope === undefined ? lifecycleRpc(client, "eth_gasPrice") : undefined,
358
+ hasTransactions && reviewedEnvelope === undefined ? selectedGasPrice ?? readLifecycleGasPrice(client) : undefined,
359
359
  ]));
360
360
  })().then((observations) => ({ observations }), (failure: unknown) => ({ failure }));
361
361
  // Refused inputs may discard this read-only preparation before it settles.
@@ -419,8 +419,9 @@ export function prepareLaunchSimulation(options: SimulateLaunchPlanOptions, bloc
419
419
  await capabilityPending;
420
420
  assertCurrent();
421
421
  };
422
- const simulation = await simulateLaunchTransactionsWithContext(options, transactions, context, result.observations, proveBackend, assertCurrent, diagnostic, nextEnvelope);
422
+ const simulation = await simulateLaunchTransactionsWithContext(options, transactions, context, result.observations, proveBackend, assertCurrent, diagnostic, nextEnvelope, selectedGasPrice);
423
423
  assertCurrent();
424
+ registerLifecycleFinalCheck(client, result.observations[0].assertCurrent);
424
425
  return simulation;
425
426
  } catch (failure) {
426
427
  closed = true;
@@ -455,6 +456,7 @@ async function simulateLaunchTransactionsWithContext(
455
456
  assertCurrent: () => void,
456
457
  diagnostic: boolean,
457
458
  nextEnvelope?: NextTransactionEnvelope,
459
+ selectedGasPrice?: bigint,
458
460
  ): Promise<LifecycleSimulation> {
459
461
  const { client, planned } = options;
460
462
  const sourceClient = lifecycleSourceClient(client);
@@ -504,11 +506,11 @@ async function simulateLaunchTransactionsWithContext(
504
506
  return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], failureCategory: "capacity", capacityConstraint: exceedsCompute ? "compute" : "simulation-gas", reason };
505
507
  }
506
508
  }
507
- const recommendedGasPrice = nextEnvelope === undefined ? rpcQuantity(gasPriceValue, "gas price") : nextEnvelope.gasPrice;
509
+ const recommendedGasPrice = nextEnvelope === undefined ? typeof gasPriceValue === "bigint" ? gasPriceValue : rpcQuantity(gasPriceValue, "gas price") : nextEnvelope.gasPrice;
508
510
  // New recommendations cover lagging quotes; reviewed fees are replayed unchanged
509
511
  // under natural child fees, never refreshed or silently increased.
510
512
  const baseFeeEnvelope = (block.baseFeePerGas ?? 0n) * 2n;
511
- const gasPrice = nextEnvelope?.gasPrice ?? (recommendedGasPrice > baseFeeEnvelope ? recommendedGasPrice : baseFeeEnvelope);
513
+ const gasPrice = nextEnvelope?.gasPrice ?? selectedGasPrice ?? (recommendedGasPrice > baseFeeEnvelope ? recommendedGasPrice : baseFeeEnvelope);
512
514
  if (gasPrice >= 1n << 256n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Gas price must be a uint256 quantity");
513
515
  const buffer = (gas: bigint) => (gas * BigInt(10000 + limits.headroomBps) + 9999n) / 10000n;
514
516
  const posterGas: bigint[] = [];
@@ -76,7 +76,7 @@ export type LifecyclePostcondition =
76
76
  export type LifecycleTransaction = {
77
77
  id: string; kind: LifecycleTransactionKind; chainId: number; from: Address; to: Address; data: Hex; value: bigint;
78
78
  dependencies: readonly string[]; postconditions: readonly LifecyclePostcondition[];
79
- marketStart?: number; marketCount?: number; gas?: bigint; gasPrice?: bigint;
79
+ marketStart?: number; marketCount?: number; gas?: bigint; gasPrice?: bigint; nonce?: bigint;
80
80
  /** Exact encoded byte count; populated on SDK-built commands, including approvals. */
81
81
  calldataBytes?: number;
82
82
  /** Exact current-state execution admission, not a wallet batch or future guarantee. */
@@ -164,6 +164,8 @@ export type PlannedLaunch = {
164
164
  account: Address; chainId: bigint; mode: LaunchExecutionMode; confirmations: number;
165
165
  /** Opt-in proxy routing; the committed creator remains the proxy, account is its principal. */
166
166
  ufg?: { config: UfgLaunchConfig; configHash: Hash };
167
+ /** With admitted exact proof, transactions[0] is the immediate reviewed envelope,
168
+ * including nonce, gas and gasPrice. Deferred commands have no execution admission. */
167
169
  transactions: readonly LifecycleTransaction[]; prerequisites: readonly LifecycleFundingPrerequisite[];
168
170
  /** Sum of exact calldata bytes across the returned remaining commands, including approvals. */
169
171
  totalCalldataBytes: number;
@@ -186,11 +188,17 @@ export type PlanLaunchOptions = {
186
188
  /** Unstarted only: waive the funded boundary tail; all other topology checks remain required. */
187
189
  allowMissingPermanentTail?: boolean;
188
190
  };
191
+ /** Explicit atomic final gas. Omitted price freezes this invocation's recommendation;
192
+ * a supplied price is replayed exactly, never refreshed or silently increased. */
193
+ export type LifecycleFinalEnvelope = Readonly<{ gas: bigint; gasPrice?: bigint }>;
189
194
  export type PrepareAndPlanLifecycleLaunchOptions = PlanLaunchOptions & {
190
195
  onProgress?: (progress: PoolBoundLifecyclePreparationProgress) => void;
191
196
  /** Protect every ordered buy from its actual diagnostic receipt output, rounded down.
192
197
  * Omitted preserves the caller's committed minima. Only an unstarted launch may be protected. */
193
198
  buySlippageBps?: number;
199
+ /** Atomic only, with no preceding approvals. Final calldata receives one exact
200
+ * validated replay, not a new gas recommendation or a larger rescue envelope. */
201
+ fixedFinalEnvelope?: LifecycleFinalEnvelope;
194
202
  };
195
203
  export type BuildLaunchTransactionsOptions = {
196
204
  plan: LaunchPlanV1; mode: LaunchExecutionMode;