@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.
@@ -607,9 +607,7 @@ export function createLocalLaunchWallet({ privateKey, creator, chainId, orchestr
607
607
  if (request[field] < 0n) throw exampleError("INVALID_ENVELOPE", `${field} must be unsigned`);
608
608
  }
609
609
  }
610
- request.nonce = envelope.nonce === undefined
611
- ? exactNumber(await client.request({ method: "eth_getTransactionCount", params: [creator, "pending"] }), "nonce")
612
- : exactNumber(envelope.nonce, "nonce");
610
+ if (envelope.nonce !== undefined) request.nonce = exactNumber(envelope.nonce, "nonce");
613
611
  if (request.type !== undefined) {
614
612
  request.type = transactionTypes[request.type];
615
613
  if (!request.type) throw exampleError("INVALID_ENVELOPE", "Only legacy/EIP-2930/EIP-1559 envelopes are supported");
@@ -622,6 +620,16 @@ export function createLocalLaunchWallet({ privateKey, creator, chainId, orchestr
622
620
  request.maxFeePerGas ??= fees.maxFeePerGas;
623
621
  request.maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas;
624
622
  }
623
+ const [nonceValue, balanceValue] = await Promise.all([
624
+ client.request({ method: "eth_getTransactionCount", params: [creator, "pending"] }),
625
+ client.request({ method: "eth_getBalance", params: [creator, "pending"] }),
626
+ ]);
627
+ const pendingNonce = exactNumber(nonceValue, "pending nonce");
628
+ if (request.nonce !== undefined && request.nonce !== pendingNonce) throw exampleError("PENDING_NONCE_CHANGED", "Pending account nonce differs from the exact reviewed envelope");
629
+ request.nonce = pendingNonce;
630
+ const maximumFee = request.gas * (request.gasPrice ?? request.maxFeePerGas);
631
+ if (BigInt(balanceValue) < request.value + maximumFee) throw exampleError("INSUFFICIENT_PENDING_BALANCE", "Pending balance cannot fund the exact transaction value and maximum gas envelope");
632
+ await assertChain();
625
633
  const rawTransaction = await account.signTransaction(request);
626
634
  redactor.addSecret(rawTransaction);
627
635
  const wire = { ...envelope, chainId: toHex(id), nonce: toHex(request.nonce) };
@@ -494,7 +494,7 @@ async function executePlan({ client, fixture, planned, sdk, artifacts, signal, l
494
494
  const next = await sdk.buildNextTransaction({ client, planned, receipts: references, limits, submissionClient: client, reviewedTransaction, signal });
495
495
  if (!next) return { references, activation };
496
496
  assert.ok(reviewedTransaction, "No transaction outside the saved review may be submitted");
497
- for (const field of ["id", "kind", "chainId", "from", "to", "data", "value", "gas", "gasPrice"]) assert.equal(next[field], reviewedTransaction[field], `Fresh ${field} must match the saved review`);
497
+ for (const field of ["id", "kind", "chainId", "from", "to", "data", "value", "nonce", "gas", "gasPrice"]) assert.equal(next[field], reviewedTransaction[field], `Fresh ${field} must match the saved review`);
498
498
  if (retainedReferences.length) {
499
499
  const progress = await sdk.readLaunchProgress({ client, planned, receipts: references, confirmations: planned.confirmations, signal });
500
500
  assertRetainedExampleReceipts({ progress, references, artifacts });
@@ -46,7 +46,7 @@ async function send(transaction) {
46
46
  const gasPrice = transaction.gasPrice ?? BigInt(await client.request({ method: "eth_gasPrice" }));
47
47
  const head = await client.request({ method: "eth_getBlockByNumber", params: ["latest", false] });
48
48
  const ceiling = [BigInt(head.gasLimit), BigInt(policy.chainTransactionGasLimit), BigInt(policy.rpcTransactionGasLimit), BigInt(policy.accountTransactionGasLimit)].reduce((a, b) => a < b ? a : b);
49
- const wire = { from: transaction.from, to: transaction.to, data: transaction.data, value: toHex(transaction.value), gasPrice: toHex(gasPrice) };
49
+ const wire = { from: transaction.from, to: transaction.to, data: transaction.data, value: toHex(transaction.value), gasPrice: toHex(gasPrice), ...(transaction.nonce === undefined ? {} : { nonce: toHex(transaction.nonce) }) };
50
50
  const estimated = transaction.gas ?? ((BigInt(await client.request({ method: "eth_estimateGas", params: [{ ...wire, gas: toHex(ceiling) }] })) * BigInt(10000 + Number(policy.headroomBps)) + 9999n) / 10000n);
51
51
  assert.ok(estimated <= ceiling, "Unsigned helper must still fit explicit local execution admission");
52
52
  const transactionHash = await client.request({ method: "eth_sendTransaction", params: [{ ...wire, gas: toHex(estimated) }] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@black-market/sdk",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "TypeScript SDK for Abyss token launches, swaps, and lending.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,9 +3,9 @@ import { ABYSS_FEE_TIERS } from "../abyss.js";
3
3
  import { assertLaunchTopologyCoverage, getTickAtSqrtRatio } from "../launch.js";
4
4
  import { launchLifecycleAbi, lifecycleAdapterAbi, lifecycleErc20Abi } from "./abi.js";
5
5
  import { hashLaunchIdentity, hashLaunchPlan, LifecycleFundingKind, LifecyclePhase, LifecycleRewardMode, LifecycleTokenKind, LIFECYCLE_MAX_REWARD_ERC20_SUPPLY, LIFECYCLE_MAX_ERC404_SUPPLY, parseLaunchPlan, serializeLaunchPlan, type LaunchPlanV1 } from "./schema.js";
6
- import { readLaunchProgress, readLifecycleProfiles, readLifecycleTokenAtBlock, readPoolBoundHookDeployment } from "./progress.js";
6
+ import { readLaunchProgress, readLifecycleHeadProgress, readLifecycleProfiles, readLifecycleTokenAtBlock, readPoolBoundHookDeployment } from "./progress.js";
7
7
  import { decodeAbyssLifecycleMarketConfig, decodePoolBoundV4LifecycleMarketConfig, decodeV4LifecycleMarketConfig, encodePoolBoundV4LifecycleMarketConfig, isPoolBoundV4ConfigVersion, minePoolBoundHookSalt, type AbyssLifecycleMarketConfig, type V4LifecycleMarketConfig, type V4PoolBoundLifecycleMarketConfig } from "./markets.js";
8
- import { assertLifecycleBlock, assertLifecycleReadClientOpen, lifecycleFailureReason, lifecyclePinnedRpc, lifecycleStaticRpc, lifecycleRpc, lifecycleStage, readLifecycleBlock, readLifecycleChainId, readLifecycleContract, resolveLifecycleLimits, rpcHex, rpcQuantity, withLifecycleReadClient } from "./rpc.js";
8
+ import { assertLifecycleBlock, assertLifecycleReadClientOpen, lifecycleFailureReason, lifecyclePinnedRpc, lifecycleStaticRpc, lifecycleRpc, lifecycleStage, readLifecycleBlock, readLifecycleChainId, readLifecycleContract, readLifecycleGasPrice, resolveLifecycleLimits, rpcHex, rpcQuantity, withLifecycleReadClient } from "./rpc.js";
9
9
  import { prepareLaunchSimulation, simulateLaunchTransactions, type NextTransactionEnvelope } from "./simulation.js";
10
10
  import { LifecyclePlanningError, type BuildNextTransactionOptions, type CanonicalLaunchProgress, type LifecycleBlock, type LifecycleConstructionProfile, type LifecycleFundingPrerequisite, type LifecyclePoolBoundHookDeployment, type LifecycleProfile, type LifecycleRpcClient, type LifecycleSimulation, type LifecycleTransaction, type PlannedLaunch, type PlanLaunchOptions, type PrepareAndPlanLifecycleLaunchOptions, type SimulateLaunchPlanOptions } from "./types.js";
11
11
  import { deriveKnownPoolBoundHookDeployment, protectOrderedBuyMinimums, validateBuySlippageBps } from "./calibration.js";
@@ -144,7 +144,7 @@ function orderedTransactions(plan: LaunchPlanV1, mode: "atomic" | "staged", prog
144
144
  (transaction.kind !== "begin" && transaction.kind !== "atomic" || progress.canonical.phase === LifecyclePhase.None) &&
145
145
  (transaction.kind !== "prepare" || (transaction.marketStart ?? 0) >= prepared));
146
146
  const transactions = progress.canonical.phase === LifecyclePhase.None ? [...approvals, ...commands] : commands;
147
- return transactions.map((transaction, index) => ({ ...transaction, calldataBytes: (transaction.data.length - 2) / 2, dependencies: index === 0 ? [] : [transactions[index - 1]!.id] }));
147
+ return transactions.map((transaction, index) => ({ ...transaction, nonce: progress.pendingAccountNonce + BigInt(index), calldataBytes: (transaction.data.length - 2) / 2, dependencies: index === 0 ? [] : [transactions[index - 1]!.id] }));
148
148
  }
149
149
 
150
150
  function attachEstimates(transactions: readonly LifecycleTransaction[], simulation: LifecycleSimulation): LifecycleTransaction[] {
@@ -154,7 +154,7 @@ function attachEstimates(transactions: readonly LifecycleTransaction[], simulati
154
154
  admission: estimate === undefined ? undefined : { admitted: simulation.admitted, confidence: simulation.confidence, executionProof: simulation.executionProof, protocolFit: simulation.protocolFit, transportPreflight: simulation.transportPreflight, blockNumber: simulation.blockNumber, blockHash: simulation.blockHash, account: simulation.account, chainId: simulation.chainId, reason: simulation.reason, limits: simulation.limits } };
155
155
  });
156
156
  }
157
- type ReviewedNextTransaction = Pick<LifecycleTransaction, "id" | "kind" | "chainId" | "from" | "to" | "data" | "value"> & NextTransactionEnvelope;
157
+ type ReviewedNextTransaction = Pick<LifecycleTransaction, "id" | "kind" | "chainId" | "from" | "to" | "data" | "value" | "nonce"> & NextTransactionEnvelope;
158
158
  const reviewedTransactionKinds = ["approve-reset", "approve", "atomic", "begin", "prepare", "activate", "cancel"] as const;
159
159
 
160
160
  function readReviewedNextTransaction(transaction: LifecycleTransaction | undefined): ReviewedNextTransaction | undefined {
@@ -167,7 +167,8 @@ function readReviewedNextTransaction(transaction: LifecycleTransaction | undefin
167
167
  typeof transaction.data !== "string" || !/^0x(?:[\da-fA-F]{2})*$/.test(transaction.data) ||
168
168
  typeof transaction.value !== "bigint" || transaction.value < 0n || transaction.value >= 1n << 256n ||
169
169
  typeof transaction.gas !== "bigint" || transaction.gas <= 0n || transaction.gas >= 1n << 64n ||
170
- typeof transaction.gasPrice !== "bigint" || transaction.gasPrice < 0n || transaction.gasPrice >= 1n << 256n) {
170
+ typeof transaction.gasPrice !== "bigint" || transaction.gasPrice < 0n || transaction.gasPrice >= 1n << 256n ||
171
+ transaction.nonce !== undefined && (typeof transaction.nonce !== "bigint" || transaction.nonce < 0n || transaction.nonce >= 1n << 64n)) {
171
172
  throw new LifecyclePlanningError("INVALID_REVIEWED_TRANSACTION", "Reviewed transaction must contain a well-formed exact chain/account/target/calldata/value and uint64 gas/uint256 price envelope");
172
173
  }
173
174
  return transaction as ReviewedNextTransaction;
@@ -176,7 +177,8 @@ function readReviewedNextTransaction(transaction: LifecycleTransaction | undefin
176
177
  function matchesReviewedNextTransaction(next: LifecycleTransaction | ReviewedNextTransaction | undefined, reviewed: ReviewedNextTransaction): boolean {
177
178
  return next !== undefined && next.id === reviewed.id && next.kind === reviewed.kind && next.chainId === reviewed.chainId &&
178
179
  next.from.toLowerCase() === reviewed.from.toLowerCase() && next.to.toLowerCase() === reviewed.to.toLowerCase() &&
179
- next.data.toLowerCase() === reviewed.data.toLowerCase() && next.value === reviewed.value;
180
+ next.data.toLowerCase() === reviewed.data.toLowerCase() && next.value === reviewed.value &&
181
+ (reviewed.nonce === undefined || next.nonce === reviewed.nonce);
180
182
  }
181
183
 
182
184
  function assertReviewedTransactionUnchanged(options: BuildNextTransactionOptions, reviewed: ReviewedNextTransaction | undefined, next: LifecycleTransaction): void {
@@ -273,16 +275,24 @@ function assertFinalPlan(planned: PlannedLaunch, signal?: AbortSignal): void {
273
275
  export async function prepareAndPlanLifecycleLaunch(options: PrepareAndPlanLifecycleLaunchOptions): Promise<PlannedLaunch> {
274
276
  const { commitment, ufgConfigHash } = validatePlanningIntent(options);
275
277
  if (options.buySlippageBps !== undefined) validateBuySlippageBps(options.buySlippageBps);
278
+ const fixed = options.fixedFinalEnvelope;
279
+ if (fixed !== undefined && (fixed === null || typeof fixed !== "object" || options.mode !== "atomic" || typeof fixed.gas !== "bigint" || fixed.gas <= 0n || fixed.gas >= 1n << 64n ||
280
+ fixed.gasPrice !== undefined && (typeof fixed.gasPrice !== "bigint" || fixed.gasPrice < 0n || fixed.gasPrice >= 1n << 256n))) {
281
+ throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Fixed final envelope requires atomic mode, exact positive uint64 gas and optional uint256 price");
282
+ }
283
+ const heldEnvelope = fixed === undefined ? undefined : Object.freeze({ ...fixed });
276
284
  const needsMining = options.plan.markets.some((market) => isPoolBoundV4ConfigVersion(market.configVersion));
277
285
  const needsProtection = options.buySlippageBps !== undefined && options.plan.buys.length !== 0;
278
- if (!needsMining && !needsProtection) return planLaunch(options);
286
+ if (!needsMining && !needsProtection && fixed === undefined) return planLaunch(options);
279
287
  const draft = parseLaunchPlan(serializeLaunchPlan(options.plan));
280
288
  const assertDraft = () => {
281
289
  assertPlanningActive(options.signal);
282
290
  if (hashLaunchPlan(options.plan).toLowerCase() !== commitment.toLowerCase()) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed during asynchronous preparation; previous construction is invalid");
283
291
  if ((options.ufg === undefined ? undefined : hashUfgLaunchConfig(options.ufg.config)) !== ufgConfigHash) throw new LifecyclePlanningError("PLAN_MUTATED", "UFG config changed during asynchronous preparation");
292
+ if (fixed === undefined ? options.fixedFinalEnvelope !== undefined : options.fixedFinalEnvelope !== fixed ||
293
+ fixed.gas !== heldEnvelope!.gas || fixed.gasPrice !== heldEnvelope!.gasPrice) throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Fixed final envelope changed during preparation");
284
294
  };
285
- return withLifecycleReadClient(options.client, async (client) => {
295
+ const result = await withLifecycleReadClient(options.client, async (client) => {
286
296
  const [block, chainId] = await Promise.all([readLifecycleBlock(client), readLifecycleChainId(client)]);
287
297
  if (chainId !== draft.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain differs from the economic plan being finalized");
288
298
  const metadata = needsMining ? await Promise.all(draft.markets.map((market, marketIndex) => isPoolBoundV4ConfigVersion(market.configVersion)
@@ -307,13 +317,19 @@ export async function prepareAndPlanLifecycleLaunch(options: PrepareAndPlanLifec
307
317
  // start at a fresh post-mining snapshot and share it through final protection.
308
318
  const executionBlock = needsMining ? await readLifecycleBlock(client) : block;
309
319
  assertDraft();
320
+ const price = heldEnvelope === undefined ? undefined : heldEnvelope.gasPrice ?? rpcQuantity(await readLifecycleGasPrice(client), "gas price");
321
+ const baseFeeEnvelope = (executionBlock.baseFeePerGas ?? 0n) * 2n;
322
+ const finalEnvelope = heldEnvelope === undefined ? undefined : Object.freeze({
323
+ gas: heldEnvelope.gas,
324
+ gasPrice: heldEnvelope.gasPrice ?? (price! > baseFeeEnvelope ? price! : baseFeeEnvelope),
325
+ });
310
326
  let plan: LaunchPlanV1 = { ...draft, markets };
311
327
  let ownedProgress: CanonicalLaunchProgress | undefined;
312
328
  if (needsProtection) {
313
329
  // A diagnostic minimum is never returned as an admitted or executable plan.
314
330
  // Price guards, funding minima, recipients and all ordered budgets stay exact.
315
331
  const diagnosticPlan = { ...plan, buys: plan.buys.map((buy) => ({ ...buy, minTokenOut: 1n })) };
316
- const diagnostic = await lifecycleStage(client, "plan", () => planLaunchWithReads({ ...options, plan: diagnosticPlan }, client, executionBlock, undefined, true));
332
+ const diagnostic = await lifecycleStage(client, "plan", () => planLaunchWithReads({ ...options, plan: diagnosticPlan }, client, executionBlock, undefined, true, undefined, finalEnvelope));
317
333
  assertDraft();
318
334
  if (diagnostic.progress.canonical.phase !== LifecyclePhase.None || diagnostic.progress.head.phase !== LifecyclePhase.None) throw new LifecyclePlanningError("LAUNCH_ALREADY_STARTED", "Buy protection cannot replace an already committed launch plan");
319
335
  plan = protectOrderedBuyMinimums(diagnostic, options.buySlippageBps!);
@@ -321,7 +337,7 @@ export async function prepareAndPlanLifecycleLaunch(options: PrepareAndPlanLifec
321
337
  // state. No onchain commitment existed at this exact snapshot to replace.
322
338
  ownedProgress = { ...diagnostic.progress, planHash: hashLaunchPlan(plan) };
323
339
  }
324
- const planned = await lifecycleStage(client, "plan", () => planLaunchWithReads({ ...options, plan }, client, executionBlock, ownedProgress));
340
+ const planned = await lifecycleStage(client, "plan", () => planLaunchWithReads({ ...options, plan }, client, executionBlock, ownedProgress, false, undefined, finalEnvelope));
325
341
  assertDraft();
326
342
  assertFinalPlan(planned, options.signal);
327
343
  if (planned.progress.canonical.phase !== LifecyclePhase.Active && planned.progress.canonical.phase !== LifecyclePhase.Cancelled) {
@@ -333,18 +349,23 @@ export async function prepareAndPlanLifecycleLaunch(options: PrepareAndPlanLifec
333
349
  if (needsProtection && !planned.simulation.admitted) throw new LifecyclePlanningError("PROTECTED_PLAN_NOT_ADMITTED", planned.simulation.reason ?? "The final protected launch did not establish exact execution proof", planned.simulation);
334
350
  return planned;
335
351
  });
352
+ assertDraft();
353
+ assertFinalPlan(result, options.signal);
354
+ return result;
336
355
  }
337
356
 
338
357
  /** Independently fresh current-state admission of an already finalized commitment. No broadcasts. */
339
358
  export async function planLaunch(options: PlanLaunchOptions): Promise<PlannedLaunch> {
340
- return withLifecycleReadClient(options.client, async (client) => {
359
+ const result = await withLifecycleReadClient(options.client, async (client) => {
341
360
  const planned = await lifecycleStage(client, "plan", () => planLaunchWithReads(options, client));
342
361
  assertFinalPlan(planned, options.signal);
343
362
  return planned;
344
363
  });
364
+ assertFinalPlan(result, options.signal);
365
+ return result;
345
366
  }
346
367
 
347
- async function planLaunchWithReads(options: PlanLaunchOptions, client: LifecycleRpcClient, pinnedBlock?: LifecycleBlock, ownedProgress?: CanonicalLaunchProgress, diagnostic = false, reviewed?: ReviewedNextTransaction): Promise<PlannedLaunch> {
368
+ async function planLaunchWithReads(options: PlanLaunchOptions, client: LifecycleRpcClient, pinnedBlock?: LifecycleBlock, ownedProgress?: CanonicalLaunchProgress, diagnostic = false, reviewed?: ReviewedNextTransaction, fixedFinalEnvelope?: NextTransactionEnvelope): Promise<PlannedLaunch> {
348
369
  const { plan, account, mode, ufg } = options;
349
370
  const { commitment: planHash, marketConfigs, ufgConfigHash } = validatePlanningIntent(options);
350
371
  const launchId = hashLaunchIdentity(plan);
@@ -366,7 +387,10 @@ async function planLaunchWithReads(options: PlanLaunchOptions, client: Lifecycle
366
387
  preset ?? readTokenFactoryBinding(client, plan.orchestrator, block),
367
388
  resolveLifecycleLimits({ client, account, orchestrator: plan.orchestrator, block, chainId: plan.chainId }, options.limits),
368
389
  lifecycleStage(client, "plan.progress", async () => {
369
- const predictedToken = await readLifecycleTokenAtBlock(client, plan, block);
390
+ const [predictedToken] = await Promise.all([
391
+ readLifecycleTokenAtBlock(client, plan, block),
392
+ ownedProgress === undefined ? readLifecycleHeadProgress(client, plan, account, block) : undefined,
393
+ ]);
370
394
  const identity = { plan, planHash, launchId, predictedToken, account, mode, confirmations: options.confirmations ?? 1,
371
395
  ...(ufg === undefined ? {} : { ufg: { config: ufg.config, configHash: ufgConfigHash! } }) };
372
396
  if (ufg !== undefined) validateUfgLaunchPlan(plan, ufg.config, predictedToken);
@@ -419,6 +443,9 @@ async function planLaunchWithReads(options: PlanLaunchOptions, client: Lifecycle
419
443
  batches = [...(prepared > 0 ? [prepared] : []), count, ...(start + count < plan.markets.length ? [plan.markets.length - start - count] : [])];
420
444
  }
421
445
  let transactions = orderedTransactions(plan, mode, progress, funding.approvals, batches, ufg);
446
+ if (fixedFinalEnvelope !== undefined && (transactions.length !== 1 || transactions[0]!.kind !== "atomic")) {
447
+ throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Fixed final envelope requires one immediate atomic launch with no preceding approvals");
448
+ }
422
449
  if (reviewed !== undefined) {
423
450
  if (!matchesReviewedNextTransaction(transactions[0], reviewed)) throw new LifecyclePlanningError("REVIEWED_TRANSACTION_MISMATCH", "Reviewed transaction differs from the current canonical unfinished next step");
424
451
  transactions = transactions.slice(0, 1);
@@ -427,9 +454,10 @@ async function planLaunchWithReads(options: PlanLaunchOptions, client: Lifecycle
427
454
  await assertLifecycleBlock(client, block, plan.chainId);
428
455
  return { ...planned, simulation: { ...placeholder, confidence: "stateful", reason: metadata.reasons.join("; "), failureCategory: "semantic" }, transactions, totalCalldataBytes: transactions.reduce((total, transaction) => total + (transaction.data.length - 2) / 2, 0) };
429
456
  }
430
- const preparedSimulation = prepareLaunchSimulation({ client, planned, limits: options.limits, fork: options.fork, signal: options.signal }, block, true, reviewed);
457
+ const envelope = diagnostic ? undefined : reviewed ?? fixedFinalEnvelope;
458
+ const preparedSimulation = prepareLaunchSimulation({ client, planned, limits: options.limits, fork: options.fork, signal: options.signal }, block, true, envelope, fixedFinalEnvelope?.gasPrice);
431
459
  try {
432
- const execute = diagnostic ? preparedSimulation.measure : (candidate: readonly LifecycleTransaction[]) => preparedSimulation(candidate, reviewed);
460
+ const execute = diagnostic ? preparedSimulation.measure : (candidate: readonly LifecycleTransaction[]) => preparedSimulation(candidate, envelope);
433
461
  let simulation = await execute(transactions);
434
462
  assertFinalPlan(planned, options.signal);
435
463
  if (mode === "staged") {
@@ -475,6 +503,7 @@ async function preflightNextTransaction(options: BuildNextTransactionOptions, ne
475
503
  if (rpcQuantity(await lifecycleRpc(submissionClient, "eth_chainId"), "submission chain ID") !== simulation.chainId) throw new Error("Active submission RPC chain differs from the reviewed transaction");
476
504
  const estimated = rpcQuantity(await lifecycleRpc(submissionClient, "eth_estimateGas", [{
477
505
  from: next.from, to: next.to, data: next.data, value: toHex(next.value), gas: toHex(next.gas), gasPrice: toHex(next.gasPrice),
506
+ ...(next.nonce === undefined ? {} : { nonce: toHex(next.nonce) }),
478
507
  }, "latest"]), "submission gas estimate");
479
508
  if (estimated <= 0n || estimated > next.gas) throw new Error("Submission RPC gas estimate exceeds the exact reviewed envelope or is zero");
480
509
  if (rpcQuantity(await lifecycleRpc(submissionClient, "eth_chainId"), "submission chain ID") !== simulation.chainId) throw new Error("Active submission RPC chain changed during preflight");
@@ -484,12 +513,6 @@ async function preflightNextTransaction(options: BuildNextTransactionOptions, ne
484
513
  const reason = `Exact next-transaction submission preflight failed: ${lifecycleFailureReason(failure)}`;
485
514
  throw new LifecyclePlanningError("SUBMISSION_PREFLIGHT_FAILED", reason, { ...simulation, transportPreflight: "failed", reason });
486
515
  }
487
- const [canonical, chainId] = await Promise.all([
488
- readLifecycleBlock(options.client, toHex(simulation.blockNumber)),
489
- lifecycleRpc(options.client, "eth_chainId"),
490
- ]);
491
- if (canonical.hash.toLowerCase() !== simulation.blockHash.toLowerCase()) throw new LifecyclePlanningError("STATE_REORGED", "Pinned launch proof changed during submission preflight; rebuild from canonical progress");
492
- if (rpcQuantity(chainId, "chain ID") !== simulation.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Source RPC chain changed during submission preflight");
493
516
  if (next.admission === undefined) throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Next transaction omitted its exact simulation admission", simulation);
494
517
  assertPlanningActive(options.signal);
495
518
  assertReviewedTransactionUnchanged(options, reviewed, next);
@@ -503,9 +526,17 @@ export async function buildNextTransaction(options: BuildNextTransactionOptions)
503
526
  // Snapshot only the reviewed wire fields; caller proofs and commands are never
504
527
  // reused, and final checks compare the mutable input with this one local copy.
505
528
  const reviewed = supplied === undefined ? undefined : Object.freeze({ id: supplied.id, kind: supplied.kind, chainId: supplied.chainId,
506
- from: supplied.from, to: supplied.to, data: supplied.data, value: supplied.value, gas: supplied.gas, gasPrice: supplied.gasPrice });
507
- return withLifecycleReadClient(options.client, async (client) => {
529
+ from: supplied.from, to: supplied.to, data: supplied.data, value: supplied.value, gas: supplied.gas, gasPrice: supplied.gasPrice, nonce: supplied.nonce });
530
+ const result = await withLifecycleReadClient(options.client, async (client) => {
508
531
  const block = await readLifecycleBlock(client);
532
+ const limitSource = options.limits === undefined ? options.planned.limits : options.limits;
533
+ if (getKnownLifecycleDeployment({ chainId: options.planned.chainId, orchestrator: options.planned.plan.orchestrator }) !== undefined && typeof limitSource !== "function") {
534
+ await Promise.all([
535
+ readLifecycleTokenAtBlock(client, options.planned.plan, block),
536
+ readLifecycleHeadProgress(client, options.planned.plan, options.planned.account, block),
537
+ resolveLifecycleLimits({ client, account: options.planned.account, orchestrator: options.planned.plan.orchestrator, block, chainId: options.planned.chainId }, limitSource),
538
+ ]);
539
+ }
509
540
  const progress = await readLaunchProgress({ ...options, client }, block);
510
541
  if (progress.canonical.phase === LifecyclePhase.Active || progress.canonical.phase === LifecyclePhase.Cancelled) {
511
542
  // Only the confirmed canonical terminal phase is terminal; a head-only
@@ -523,6 +554,7 @@ export async function buildNextTransaction(options: BuildNextTransactionOptions)
523
554
  assertFinalPlan(planned, options.signal);
524
555
  const transaction: LifecycleTransaction = planned.ufg !== undefined ? buildUfgCancelTransaction({ plan: planned.plan, preparedMarkets: progress.canonical.preparedMarkets })
525
556
  : { id: "cancel", kind: "cancel", chainId: Number(planned.chainId), from: planned.account, to: planned.plan.orchestrator, data: encodeFunctionData({ abi: launchLifecycleAbi, functionName: "cancelLaunch", args: [planned.plan] }), value: 0n, dependencies: [], postconditions: [{ kind: "launch", phase: "Cancelled", preparedMarkets: progress.canonical.preparedMarkets }] };
557
+ transaction.nonce = progress.pendingAccountNonce;
526
558
  if (reviewed !== undefined && !matchesReviewedNextTransaction(transaction, reviewed)) throw new LifecyclePlanningError("REVIEWED_TRANSACTION_MISMATCH", "Reviewed transaction differs from the current canonical cancellation");
527
559
  const simulation = await simulateLaunchTransactions({ client, planned, limits: options.limits === undefined ? planned.limits : options.limits, fork: options.fork, signal: options.signal }, [transaction], block, reviewed);
528
560
  if (!simulation.admitted) throw new LifecyclePlanningError("CANCEL_NOT_ADMITTED", simulation.reason ?? "Cancellation could not be proved against current canonical state", simulation);
@@ -539,6 +571,9 @@ export async function buildNextTransaction(options: BuildNextTransactionOptions)
539
571
  if (next === undefined) return undefined;
540
572
  return preflightNextTransaction(options, next, refreshed.simulation, reviewed);
541
573
  });
574
+ assertFinalPlan(options.planned, options.signal);
575
+ if (result !== undefined) assertReviewedTransactionUnchanged(options, reviewed, result);
576
+ return result;
542
577
  }
543
578
 
544
579
  /** Re-simulates only canonical unfinished work with the same submitted receipt
@@ -2,7 +2,7 @@ import { concatHex, decodeFunctionData, encodeAbiParameters, encodeFunctionData,
2
2
  import { launchLifecycleAbi, lifecycleAdapterAbi, lifecycleDirectoryAbi, lifecycleErc20Abi, lifecycleOracleFactoryAbi, lifecycleRegistryAbi, poolFeeCollectorFactoryV1Abi, poolFeeCollectorFactoryConfigV6Abi, lifecycleV4HookAbi, lifecycleV4LockerAbi, poolHookDeployerV1Abi, poolHookDeployerConfigV6Abi, sharedHookDeployerV1Abi, fixedFeePoolHookV1Abi, fixedFeePoolHookConfigV6Abi, poolMarketAdapterV1Abi, sharedMarketAdapterV1Abi } from "./abi.js";
3
3
  import { buildPoolBoundHookParameters, decodePoolBoundV4LifecycleMarketConfig, encodePoolBoundHookParameters, hasLifecycleV4HookPermissions, isPoolBoundV4ConfigVersion, poolBoundHookInitCodeHash, predictPoolBoundHookAddress, validateV4LifecycleMarket, V4_LIFECYCLE_CONFIG_SCHEMA, V4_POOL_BOUND_LIFECYCLE_CONFIG_SCHEMA_V5, V4_POOL_BOUND_LIFECYCLE_CONFIG_SCHEMA_V6, type PoolBoundHookParameters, type PoolBoundLifecycleConfigVersion } from "./markets.js";
4
4
  import { hashLaunchIdentity, hashLaunchPlan, hashLaunchBounds, hashLaunchDependencies, hashLifecycleProfile, lifecycleTokenComponents, launchProgressV1Components, LifecycleMode, LifecyclePhase, LifecycleVenue, LIFECYCLE_REQUIRED_CAPABILITIES, LIFECYCLE_ERC404_CAPABILITY, LIFECYCLE_MULTI_POSITION_CAPABILITY, type AdapterRegistrationV1, type LaunchPlanV1, type LaunchProgressV1, type MarketIdentityV1, type MarketLiveStateV1, type PreparedMarketV1, type ProfileRegistrationV1, type ProfileTopologyV1, type LaunchEnvelopeV2, type LifecycleDeveloperTerms } from "./schema.js";
5
- import { assertLifecycleBlock, assertLifecycleReadClientOpen, lifecycleFailureReason, lifecyclePinnedRpc, lifecycleStaticRpc, lifecycleRpc, lifecycleStage, readLifecycleBlock, readLifecycleChainId, readLifecycleContract, readLifecycleStaticContract, readLifecycleProfileTopology, rpcHex, rpcObject, rpcQuantity, withLifecycleReadClient } from "./rpc.js";
5
+ import { assertLifecycleBlock, assertLifecycleReadClientOpen, lifecycleFailureReason, lifecyclePinnedRpc, lifecycleStaticRpc, lifecycleRpc, lifecycleStage, readLifecycleBlock, readLifecycleChainId, readLifecycleContract, readLifecycleImmutable, readLifecycleStaticContract, readLifecycleProfileTopology, rpcHex, rpcObject, rpcQuantity, withLifecycleReadClient } from "./rpc.js";
6
6
  import { LifecyclePlanningError, type CanonicalLaunchProgress, type LifecycleBlock, type LifecycleMarketProgress, type LifecycleProfile, type LifecycleReceiptReference, type LifecycleReceiptStatus, type LifecycleRpcClient, type PoolBoundHookDeployment, type ReadLaunchProgressOptions } from "./types.js";
7
7
  import { deriveKnownPoolBoundHookDeployment } from "./calibration.js";
8
8
  import { getKnownLifecycleDeployment, getKnownLifecycleProfile } from "./presets.js";
@@ -21,9 +21,18 @@ const lifecycleTokenPredictionAbi = [{
21
21
  * avoiding full-plan prediction reads after salts or buy minima change. */
22
22
  export async function readLifecycleTokenAtBlock(client: LifecycleRpcClient, plan: LaunchPlanV1, block: LifecycleBlock): Promise<Address> {
23
23
  const deployment = getKnownLifecycleDeployment({ chainId: plan.chainId, orchestrator: plan.orchestrator });
24
- return deployment === undefined
25
- ? readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "predictToken", [plan], block)
26
- : readLifecycleContract<Address>(client, deployment.tokenFactory, lifecycleTokenPredictionAbi, "predictToken", [hashLaunchIdentity(plan), plan.token], block);
24
+ if (deployment === undefined) return readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "predictToken", [plan], block);
25
+ const launchId = hashLaunchIdentity(plan);
26
+ const data = encodeFunctionData({ abi: lifecycleTokenPredictionAbi, functionName: "predictToken", args: [launchId, plan.token] });
27
+ const key = `${plan.chainId}:${plan.orchestrator.toLowerCase()}:${deployment.tokenFactory.toLowerCase()}:${deployment.tokenFactoryCodeHash}:${data}`;
28
+ return readLifecycleImmutable(client, key, () =>
29
+ readLifecycleContract<Address>(client, deployment.tokenFactory, lifecycleTokenPredictionAbi, "predictToken", [launchId, plan.token], block, undefined, false, true));
30
+ }
31
+
32
+ /** Internal pinned head observation. Known cores do not inspect msg.sender. */
33
+ export function readLifecycleHeadProgress(client: LifecycleRpcClient, plan: LaunchPlanV1, account: Address, block: LifecycleBlock): Promise<LaunchProgressV1> {
34
+ const known = getKnownLifecycleDeployment({ chainId: plan.chainId, orchestrator: plan.orchestrator }) !== undefined;
35
+ return readLifecycleContract<LaunchProgressV1>(client, plan.orchestrator, launchLifecycleAbi, "readLaunchProgress", [hashLaunchIdentity(plan)], block, account, false, known);
27
36
  }
28
37
 
29
38
  export async function predictLifecycleToken(options: { client: LifecycleRpcClient; plan: LaunchPlanV1 }): Promise<Address> {
@@ -224,7 +233,7 @@ export async function validateV4LifecycleOracle(options: {
224
233
 
225
234
  export async function readLifecycleProfiles(options: { client: LifecycleRpcClient; orchestrator: Address; profileIds?: readonly Hex[]; offset?: bigint; limit?: bigint }, pinnedBlock?: LifecycleBlock): Promise<LifecycleProfile[]> {
226
235
  return withLifecycleReadClient(options.client, (client) => lifecycleStage(client, "profile.certification", async () => {
227
- const chainIdPromise = lifecycleRpc(client, "eth_chainId").then((value) => rpcQuantity(value, "chain ID"));
236
+ const chainIdPromise = readLifecycleChainId(client);
228
237
  // Block-bound discovery does not depend on chain identity. Join both branches
229
238
  // immediately so a failed chain read cannot become detached or a profile refusal.
230
239
  const [chainId, { block, profiles }] = await Promise.all([
@@ -303,7 +312,7 @@ async function readPoolBoundHookDeploymentDetails(options: { client: LifecycleRp
303
312
  const market = plan.markets[marketIndex];
304
313
  if (market === undefined || !isPoolBoundV4ConfigVersion(market.configVersion)) throw new LifecyclePlanningError("INVALID_BOUND_MARKET", "Market index must select explicit pool-bound config version 5 or 6");
305
314
  let chainId: bigint;
306
- try { chainId = rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID"); }
315
+ try { chainId = await readLifecycleChainId(client); }
307
316
  finally { assertDerivedReadClientOpen(client); }
308
317
  if (hashLaunchPlan(plan) !== commitment) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed while observing the hook deployment chain");
309
318
  if (chainId !== plan.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain differs from the committed plan");
@@ -419,19 +428,21 @@ async function readPoolBoundHookDeploymentDetailsFresh(options: { client: Lifecy
419
428
  export async function readPoolBoundHookDeployment(options: { client: LifecycleRpcClient; plan: LaunchPlanV1; marketIndex: number }, pinnedBlock?: LifecycleBlock): Promise<PoolBoundHookDeployment> {
420
429
  const commitment = hashLaunchPlan(options.plan);
421
430
  const block = pinnedBlock ?? await readLifecycleBlock(options.client);
422
- return withLifecycleReadClient(options.client, async (client) => {
431
+ const result = await withLifecycleReadClient(options.client, async (client) => {
423
432
  const { deployment } = await readPoolBoundHookDeploymentDetails({ ...options, client }, block, commitment);
424
433
  if (hashLaunchPlan(options.plan) !== commitment) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed while reading hook deployment metadata");
425
434
  if (pinnedBlock === undefined) await assertLifecycleBlock(client, block, options.plan.chainId);
426
435
  return { ...deployment };
427
436
  });
437
+ if (hashLaunchPlan(options.plan) !== commitment) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed during the final hook deployment guard");
438
+ return result;
428
439
  }
429
440
 
430
441
  /** Optional permissionless predeployment calldata; no token deployment, pool registration or wallet broadcast. */
431
442
  export async function buildPoolBoundHookDeploymentTransaction(options: { client: LifecycleRpcClient; plan: LaunchPlanV1; marketIndex: number }): Promise<{ to: Address; data: Hex; value: bigint; deployment: PoolBoundHookDeployment }> {
432
443
  const commitment = hashLaunchPlan(options.plan);
433
444
  const block = await readLifecycleBlock(options.client);
434
- return withLifecycleReadClient(options.client, async (client) => {
445
+ const result = await withLifecycleReadClient(options.client, async (client) => {
435
446
  const { deployment, parameters, configVersion } = await readPoolBoundHookDeploymentDetails({ ...options, client }, block, commitment);
436
447
  if (!hasLifecycleV4HookPermissions(deployment.predictedHook)) throw new LifecyclePlanningError("INVALID_HOOK_BITS", "Finalize a salt with the exact lifecycle hook permissions before predeployment");
437
448
  if (hashLaunchPlan(options.plan) !== commitment) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed while constructing hook deployment calldata");
@@ -439,6 +450,8 @@ export async function buildPoolBoundHookDeploymentTransaction(options: { client:
439
450
  const abi = configVersion === 5 ? poolHookDeployerV1Abi : poolHookDeployerConfigV6Abi;
440
451
  return { to: deployment.deployer, data: encodeFunctionData({ abi, functionName: "deploy", args: [parameters, deployment.salt] }), value: 0n, deployment: { ...deployment } };
441
452
  });
453
+ if (hashLaunchPlan(options.plan) !== commitment) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed during the final hook transaction guard");
454
+ return result;
442
455
  }
443
456
 
444
457
  export function validateLifecycleMarketIdentity(identity: MarketIdentityV1, plan: LaunchPlanV1, token: Address, marketIndex: number): void {
@@ -455,7 +468,7 @@ export function validateLifecycleMarketIdentity(identity: MarketIdentityV1, plan
455
468
  } else if (identity.venue !== LifecycleVenue.Abyss || identity.pool === zeroAddress || identity.factory === zeroAddress) throw new LifecyclePlanningError("MARKET_IDENTITY", "Abyss canonical factory/pool identity is missing");
456
469
  }
457
470
 
458
- async function transactionMatchesLaunch(transaction: Record<string, unknown>, options: ReadLaunchProgressOptions, block: LifecycleBlock): Promise<boolean> {
471
+ async function transactionMatchesLaunch(transaction: Record<string, unknown>, options: ReadLaunchProgressOptions, readBlock: () => Promise<LifecycleBlock>): Promise<boolean> {
459
472
  const { planned } = options;
460
473
  const from = rpcHex(transaction.from, "receipt transaction account");
461
474
  if (from.toLowerCase() !== planned.account.toLowerCase()) throw new LifecyclePlanningError("RECEIPT_ACCOUNT", "Receipt transaction belongs to a different account");
@@ -484,53 +497,103 @@ async function transactionMatchesLaunch(transaction: Record<string, unknown>, op
484
497
  if (decoded.functionName !== "approve" || rpcQuantity(transaction.value ?? "0x0", "approval native value") !== 0n) return false;
485
498
  spender = decoded.args[0];
486
499
  } catch { return false; }
487
- const escrow = planned.ufg !== undefined ? UFG_LAUNCH_PROXY_ADDRESS : await readLifecycleContract<Address>(options.client, planned.plan.orchestrator, launchLifecycleAbi, "fundingEscrow", [], block);
500
+ const known = getKnownLifecycleDeployment({ chainId: planned.plan.chainId, orchestrator: planned.plan.orchestrator });
501
+ const escrow = planned.ufg !== undefined ? UFG_LAUNCH_PROXY_ADDRESS : known?.fundingEscrow ??
502
+ await readLifecycleContract<Address>(options.client, planned.plan.orchestrator, launchLifecycleAbi, "fundingEscrow", [], await readBlock());
488
503
  return spender.toLowerCase() === escrow.toLowerCase();
489
504
  }
490
505
 
491
- async function readReceipt(reference: LifecycleReceiptReference, options: ReadLaunchProgressOptions, block: LifecycleBlock): Promise<LifecycleReceiptStatus> {
506
+ type ReceiptObservation = {
507
+ pins: readonly { number: bigint; hash: Hex }[];
508
+ finish(headers: ReadonlyMap<bigint, Hex | undefined>): LifecycleReceiptStatus;
509
+ };
510
+
511
+ async function readReceipt(reference: LifecycleReceiptReference, options: ReadLaunchProgressOptions, block: LifecycleBlock, readIdentityBlock: (number: bigint) => Promise<LifecycleBlock>): Promise<ReceiptObservation> {
492
512
  const effectiveHash = reference.replacementHash ?? reference.transactionHash;
493
513
  const base = { transactionHash: reference.transactionHash, effectiveHash, replaced: reference.replacementHash !== undefined && reference.replacementHash.toLowerCase() !== reference.transactionHash.toLowerCase() };
494
- const observedNumber = reference.observedBlockNumber;
495
- if (observedNumber !== undefined && reference.observedBlockHash !== undefined) {
496
- const observed = await lifecycleRpc(options.client, "eth_getBlockByNumber", [toHex(observedNumber), false]);
497
- if (observed === null || rpcHex(rpcObject(observed, "observed receipt block").hash, "observed receipt block hash").toLowerCase() !== reference.observedBlockHash.toLowerCase()) return { ...base, status: "reorged", blockNumber: observedNumber, blockHash: reference.observedBlockHash };
498
- }
499
- const [receiptValue, transactionValue] = await Promise.all([
500
- lifecycleRpc(options.client, "eth_getTransactionReceipt", [effectiveHash]),
501
- lifecycleRpc(options.client, "eth_getTransactionByHash", [effectiveHash]),
502
- ]);
503
- if (receiptValue === null) {
504
- // An absent receipt with an unchanged canonical observed block is not a
505
- // reorg: the transaction is simply not (yet) mined. A missing/changed
506
- // canonical block above already reported the real reorg.
507
- if (reference.observedBlockHash !== undefined) return { ...base, status: "pending", blockNumber: observedNumber, blockHash: reference.observedBlockHash };
508
- return { ...base, status: "pending" };
514
+ const pins: { number: bigint; hash: Hex }[] = [];
515
+ const observedNumber = reference.observedBlockNumber, observedHash = reference.observedBlockHash;
516
+ if (observedNumber !== undefined && observedHash !== undefined) pins.push({ number: observedNumber, hash: observedHash });
517
+ const finish = (result: LifecycleReceiptStatus, failure?: { error: unknown }): ReceiptObservation => ({
518
+ pins,
519
+ finish(headers) {
520
+ for (const pin of pins) {
521
+ if (failure !== undefined && (pin.number !== observedNumber || pin.hash !== observedHash)) continue;
522
+ if (headers.get(pin.number)?.toLowerCase() !== pin.hash.toLowerCase()) return { ...base, status: "reorged", blockNumber: pin.number, blockHash: pin.hash };
523
+ }
524
+ if (failure !== undefined) throw failure.error;
525
+ return result;
526
+ },
527
+ });
528
+ try {
529
+ const [receiptValue, transactionValue] = await Promise.all([
530
+ lifecycleRpc(options.client, "eth_getTransactionReceipt", [effectiveHash]),
531
+ lifecycleRpc(options.client, "eth_getTransactionByHash", [effectiveHash]),
532
+ ]);
533
+ if (receiptValue === null) return finish({ ...base, status: "pending", blockNumber: observedNumber, blockHash: observedHash });
534
+ if (transactionValue === null) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Receipt exists without its canonical transaction");
535
+ const receipt = rpcObject(receiptValue, "receipt");
536
+ const blockNumber = rpcQuantity(receipt.blockNumber, "receipt block number");
537
+ const blockHash = rpcHex(receipt.blockHash, "receipt block hash");
538
+ pins.push({ number: blockNumber, hash: blockHash });
539
+ const matches = await transactionMatchesLaunch(rpcObject(transactionValue, "receipt transaction"), options,
540
+ () => readIdentityBlock(blockNumber));
541
+ const confirmations = block.number >= blockNumber ? block.number - blockNumber + 1n : 0n;
542
+ const required = reference.confirmations ?? 1;
543
+ if (!Number.isSafeInteger(required) || required < 1) throw new LifecyclePlanningError("INVALID_CONFIRMATIONS", "Receipt confirmations must be a positive integer");
544
+ const observation = finish({
545
+ ...base, blockNumber, blockHash, confirmations,
546
+ status: confirmations < BigInt(required) ? "unconfirmed" : !matches ? "replacement-cancelled" :
547
+ rpcQuantity(receipt.status, "receipt status") !== 1n ? "reverted" : "confirmed",
548
+ });
549
+ return {
550
+ pins,
551
+ finish(headers) {
552
+ const result = observation.finish(headers);
553
+ if (result.status !== "reorged" && !matches && !base.replaced) throw new LifecyclePlanningError("RECEIPT_IDENTITY", "Receipt calldata does not belong to the exact committed launch");
554
+ return result;
555
+ },
556
+ };
557
+ } catch (error) {
558
+ if (error instanceof Error && error.name === "AbortError") throw error;
559
+ // A changed retained observation is a reorg even if the orphan's transaction
560
+ // or identity read is now unavailable. Otherwise preserve the original error.
561
+ return finish({ ...base, status: "pending" }, { error });
509
562
  }
510
- if (transactionValue === null) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Receipt exists without its canonical transaction");
511
- const transaction = rpcObject(transactionValue, "receipt transaction");
512
- const receiptBlock = await readLifecycleBlock(options.client, toHex(rpcQuantity(rpcObject(receiptValue, "receipt").blockNumber, "receipt block number")));
513
- const matches = await transactionMatchesLaunch(transaction, options, receiptBlock);
514
- if (!matches && !base.replaced) throw new LifecyclePlanningError("RECEIPT_IDENTITY", "Receipt calldata does not belong to the exact committed launch");
515
- const receipt = rpcObject(receiptValue, "receipt");
516
- const blockNumber = rpcQuantity(receipt.blockNumber, "receipt block number");
517
- const blockHash = rpcHex(receipt.blockHash, "receipt block hash");
518
- const canonicalBlockValue = await lifecycleRpc(options.client, "eth_getBlockByNumber", [toHex(blockNumber), false]);
519
- if (canonicalBlockValue === null || rpcHex(rpcObject(canonicalBlockValue, "receipt canonical block").hash, "receipt canonical hash").toLowerCase() !== blockHash.toLowerCase()) return { ...base, status: "reorged", blockNumber, blockHash };
520
- const confirmations = block.number >= blockNumber ? block.number - blockNumber + 1n : 0n;
521
- const required = reference.confirmations ?? 1;
522
- if (!Number.isSafeInteger(required) || required < 1) throw new LifecyclePlanningError("INVALID_CONFIRMATIONS", "Receipt confirmations must be a positive integer");
523
- if (confirmations < BigInt(required)) return { ...base, status: "unconfirmed", blockNumber, blockHash, confirmations };
524
- if (!matches) return { ...base, status: "replacement-cancelled", blockNumber, blockHash, confirmations };
525
- if (rpcQuantity(receipt.status, "receipt status") !== 1n) return { ...base, status: "reverted", blockNumber, blockHash, confirmations };
526
- return { ...base, status: "confirmed", blockNumber, blockHash, confirmations };
563
+ }
564
+
565
+ async function readReceipts(options: ReadLaunchProgressOptions, block: LifecycleBlock, confirmationDepth: number): Promise<LifecycleReceiptStatus[]> {
566
+ if (options.receipts === undefined || options.receipts.length === 0) return [];
567
+ const identityBlocks = new Map<bigint, Promise<LifecycleBlock>>();
568
+ const readIdentityBlock = (number: bigint) => {
569
+ let pending = identityBlocks.get(number);
570
+ if (pending === undefined) { pending = readLifecycleBlock(options.client, toHex(number)); identityBlocks.set(number, pending); }
571
+ return pending;
572
+ };
573
+ const observations = await Promise.all((options.receipts ?? []).map((reference) =>
574
+ readReceipt({ ...reference, confirmations: Math.max(reference.confirmations ?? 1, confirmationDepth) }, options, block, readIdentityBlock)));
575
+ const headers = new Map<bigint, Hex | undefined>();
576
+ for (const observation of observations) for (const pin of observation.pins) headers.set(pin.number, undefined);
577
+ // All asynchronous identity reads have settled. Equal observed/inclusion blocks
578
+ // and multiple receipts now share one fresh canonical header, never a stale cache.
579
+ await Promise.all([...headers.keys()].map(async (number) => {
580
+ const value = await lifecycleRpc(options.client, "eth_getBlockByNumber", [toHex(number), false]);
581
+ const hash = value === null ? undefined : rpcHex(rpcObject(value, "receipt canonical block").hash, "receipt canonical hash");
582
+ if (hash !== undefined && hash.length !== 66) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Receipt canonical block hash must have 32 bytes");
583
+ headers.set(number, hash);
584
+ }));
585
+ return observations.map((observation) => observation.finish(headers));
527
586
  }
528
587
 
529
588
  /** Reads confirmation-bound canonical state and receipt provenance; local step counters are never used. */
530
589
  export async function readLaunchProgress(options: ReadLaunchProgressOptions, pinnedBlock?: LifecycleBlock): Promise<CanonicalLaunchProgress> {
531
590
  if (options.signal?.aborted) throw Object.assign(new Error("Lifecycle progress read cancelled"), { name: "AbortError" });
532
- const block = pinnedBlock ?? await readLifecycleBlock(options.client);
533
- return withLifecycleReadClient(options.client, (client) => readLaunchProgressAtBlock(options, client, block, pinnedBlock === undefined));
591
+ const planHash = options.planned.planHash, launchId = options.planned.launchId;
592
+ const result = await withLifecycleReadClient(options.client, async (client) =>
593
+ readLaunchProgressAtBlock(options, client, pinnedBlock ?? await readLifecycleBlock(client), pinnedBlock === undefined));
594
+ if (hashLaunchPlan(options.planned.plan) !== planHash || hashLaunchIdentity(options.planned.plan) !== launchId) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed during the final canonical progress guard");
595
+ if (options.signal?.aborted) throw Object.assign(new Error("Lifecycle progress read cancelled"), { name: "AbortError" });
596
+ return result;
534
597
  }
535
598
 
536
599
  async function readLaunchProgressAtBlock(options: ReadLaunchProgressOptions, client: LifecycleRpcClient, block: LifecycleBlock, checkBlock: boolean): Promise<CanonicalLaunchProgress> {
@@ -555,7 +618,7 @@ async function readLaunchProgressAtBlock(options: ReadLaunchProgressOptions, cli
555
618
  } else if (planned.account.toLowerCase() !== planned.plan.creator.toLowerCase()) throw new LifecyclePlanningError("ACCOUNT_MISMATCH", "Execution account must be the committed creator/payer/refund account");
556
619
  if (hashLaunchPlan(planned.plan).toLowerCase() !== planned.planHash.toLowerCase() || hashLaunchIdentity(planned.plan).toLowerCase() !== planned.launchId.toLowerCase()) throw new LifecyclePlanningError("PLAN_MUTATED", "Stored economic plan no longer matches its commitment");
557
620
  const [head, confirmedProgress, token, confirmedNonce, headNonce, pendingNonce] = await Promise.all([
558
- readLifecycleContract<LaunchProgressV1>(client, planned.plan.orchestrator, launchLifecycleAbi, "readLaunchProgress", [planned.launchId], block, planned.account),
621
+ readLifecycleHeadProgress(client, planned.plan, planned.account, block),
559
622
  confirmedNumber === block.number ? undefined : readLifecycleContract<LaunchProgressV1>(client, planned.plan.orchestrator, launchLifecycleAbi, "readLaunchProgress", [planned.launchId], confirmedBlock, planned.account),
560
623
  readLifecycleTokenAtBlock(client, planned.plan, block),
561
624
  lifecycleRpc(client, "eth_getTransactionCount", [planned.account, toHex(confirmedBlock.number)]),
@@ -582,7 +645,7 @@ async function readLaunchProgressAtBlock(options: ReadLaunchProgressOptions, cli
582
645
  const pendingAccountNonce = rpcQuantity(pendingNonce, "pending account nonce");
583
646
  const progressParameters = [{ type: "tuple", components: launchProgressV1Components }] as const;
584
647
  const confirmationSafe = confirmedAccountNonce === headAccountNonce && pendingAccountNonce === headAccountNonce && keccak256(encodeAbiParameters(progressParameters, [canonical])) === keccak256(encodeAbiParameters(progressParameters, [head]));
585
- const receipts = await Promise.all((options.receipts ?? []).map((reference) => readReceipt({ ...reference, confirmations: Math.max(reference.confirmations ?? 1, confirmationDepth) }, { ...options, client }, block)));
648
+ const receipts = await readReceipts({ ...options, client }, block, confirmationDepth);
586
649
  assertActive();
587
650
  let markets: LifecycleMarketProgress[] = [];
588
651
  if (canonical.preparedMarkets > 0) {