@black-market/sdk 0.6.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +28 -4
  2. package/dist/addresses.d.ts +1 -1
  3. package/dist/addresses.js +4 -4
  4. package/dist/launch.d.ts +61 -0
  5. package/dist/launch.d.ts.map +1 -1
  6. package/dist/launch.js +171 -6
  7. package/dist/launch.js.map +1 -1
  8. package/dist/lifecycle/abi.d.ts +1999 -253
  9. package/dist/lifecycle/abi.d.ts.map +1 -1
  10. package/dist/lifecycle/abi.js +43 -14
  11. package/dist/lifecycle/abi.js.map +1 -1
  12. package/dist/lifecycle/allocations.d.ts +11 -0
  13. package/dist/lifecycle/allocations.d.ts.map +1 -0
  14. package/dist/lifecycle/allocations.js +36 -0
  15. package/dist/lifecycle/allocations.js.map +1 -0
  16. package/dist/lifecycle/calibration.d.ts +19 -0
  17. package/dist/lifecycle/calibration.d.ts.map +1 -0
  18. package/dist/lifecycle/calibration.js +65 -0
  19. package/dist/lifecycle/calibration.js.map +1 -0
  20. package/dist/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.d.ts +11 -0
  21. package/dist/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.d.ts.map +1 -0
  22. package/dist/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.js +10 -0
  23. package/dist/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.js.map +1 -0
  24. package/dist/lifecycle/index.d.ts +4 -2
  25. package/dist/lifecycle/index.d.ts.map +1 -1
  26. package/dist/lifecycle/index.js +4 -2
  27. package/dist/lifecycle/index.js.map +1 -1
  28. package/dist/lifecycle/markets.d.ts +170 -6
  29. package/dist/lifecycle/markets.d.ts.map +1 -1
  30. package/dist/lifecycle/markets.js +164 -37
  31. package/dist/lifecycle/markets.js.map +1 -1
  32. package/dist/lifecycle/planner.d.ts +7 -14
  33. package/dist/lifecycle/planner.d.ts.map +1 -1
  34. package/dist/lifecycle/planner.js +354 -400
  35. package/dist/lifecycle/planner.js.map +1 -1
  36. package/dist/lifecycle/presets.d.ts +62 -0
  37. package/dist/lifecycle/presets.d.ts.map +1 -0
  38. package/dist/lifecycle/presets.js +195 -0
  39. package/dist/lifecycle/presets.js.map +1 -0
  40. package/dist/lifecycle/progress.d.ts +5 -1
  41. package/dist/lifecycle/progress.d.ts.map +1 -1
  42. package/dist/lifecycle/progress.js +212 -53
  43. package/dist/lifecycle/progress.js.map +1 -1
  44. package/dist/lifecycle/rpc.d.ts +13 -2
  45. package/dist/lifecycle/rpc.d.ts.map +1 -1
  46. package/dist/lifecycle/rpc.js +92 -8
  47. package/dist/lifecycle/rpc.js.map +1 -1
  48. package/dist/lifecycle/simulation.d.ts +14 -4
  49. package/dist/lifecycle/simulation.d.ts.map +1 -1
  50. package/dist/lifecycle/simulation.js +381 -118
  51. package/dist/lifecycle/simulation.js.map +1 -1
  52. package/dist/lifecycle/transactions.d.ts +6 -0
  53. package/dist/lifecycle/transactions.d.ts.map +1 -0
  54. package/dist/lifecycle/transactions.js +40 -0
  55. package/dist/lifecycle/transactions.js.map +1 -0
  56. package/dist/lifecycle/types.d.ts +39 -2
  57. package/dist/lifecycle/types.d.ts.map +1 -1
  58. package/dist/lifecycle/types.js.map +1 -1
  59. package/docs/launch-lifecycle.md +228 -54
  60. package/examples/README.md +108 -18
  61. package/examples/generate-lifecycle-commitment-fixture.mjs +3 -1
  62. package/examples/launch-example-support.mjs +1 -1
  63. package/examples/launch-example.mjs +73 -26
  64. package/examples/launch-lifecycle-smoke.mjs +13 -11
  65. package/examples/launch.mjs +12 -11
  66. package/examples/quickstart.mjs +1 -1
  67. package/package.json +1 -1
  68. package/src/addresses.ts +4 -4
  69. package/src/launch.ts +213 -6
  70. package/src/lifecycle/abi.ts +43 -14
  71. package/src/lifecycle/allocations.ts +35 -0
  72. package/src/lifecycle/calibration.ts +59 -0
  73. package/src/lifecycle/deployments/fixed-fee-pool-hook-v1-20261008.ts +11 -0
  74. package/src/lifecycle/index.ts +4 -2
  75. package/src/lifecycle/markets.ts +169 -35
  76. package/src/lifecycle/planner.ts +326 -330
  77. package/src/lifecycle/presets.ts +243 -0
  78. package/src/lifecycle/progress.ts +193 -50
  79. package/src/lifecycle/rpc.ts +97 -9
  80. package/src/lifecycle/simulation.ts +369 -111
  81. package/src/lifecycle/transactions.ts +34 -0
  82. package/src/lifecycle/types.ts +35 -3
@@ -1,15 +1,19 @@
1
- import { encodeAbiParameters, encodeFunctionData, keccak256, toHex, zeroAddress, type Address, type Hex } from "viem";
2
- import { launchLifecycleAbi, lifecycleAdapterAbi, lifecycleErc20Abi, lifecycleFundingEscrowAbi, lifecycleRegistryAbi } from "./abi.js";
3
- import { hashLaunchIdentity, hashLaunchPlan, LifecycleFundingKind, LifecycleMode, LifecyclePhase, LifecycleRewardMode, LifecycleTokenKind, LifecycleVenue, LIFECYCLE_ERC404_CAPABILITY, LIFECYCLE_MAX_REWARD_ERC20_SUPPLY, LIFECYCLE_MAX_ERC404_SUPPLY, LIFECYCLE_MULTI_POSITION_CAPABILITY, LIFECYCLE_REQUIRED_CAPABILITIES, marketIdentityV1Components, parseLaunchPlan, serializeLaunchPlan, type LaunchPlanV1, type MarketIdentityV1 } from "./schema.js";
4
- import { ABYSS_LIFECYCLE_CONFIG_SCHEMA, readLaunchProgress, readLifecycleProfiles, readPoolBoundHookDeployment, validateLifecycleMarketIdentity, validateV4LifecycleOracle } from "./progress.js";
5
- import { decodeAbyssLifecycleMarketConfig, decodePoolBoundV4LifecycleMarketConfig, decodeV4LifecycleMarketConfig, encodePoolBoundV4LifecycleMarketConfig, hasLifecycleV4HookPermissions, minePoolBoundHookSalt, predictPoolBoundHookAddress, validateV4LifecycleMarket, V4_LIFECYCLE_CONFIG_SCHEMA, V4_POOL_BOUND_LIFECYCLE_CONFIG_SCHEMA, type V4LifecycleMarketConfig, type V4PoolBoundLifecycleMarketConfig } from "./markets.js";
6
- import { assertLifecycleBlock, lifecyclePinnedRpc, lifecycleRpc, lifecycleSourceClient, lifecycleStage, readLifecycleBlock, readLifecycleContract, resolveLifecycleLimits, rpcHex, rpcQuantity, withLifecycleReadClient } from "./rpc.js";
7
- import { prepareLaunchSimulation, simulateLaunchTransactions } from "./simulation.js";
8
- import { LifecyclePlanningError, type BuildNextTransactionOptions, type CanonicalLaunchProgress, type LifecycleBlock, type LifecycleFundingPrerequisite, type LifecyclePoolBoundHookDeployment, type LifecycleProfile, type LifecycleRpcClient, type LifecycleSimulation, type LifecycleTransaction, type PlannedLaunch, type PlanLaunchOptions, type PoolBoundLifecyclePreparationProgress, type SimulateLaunchPlanOptions } from "./types.js";
1
+ import { encodeFunctionData, keccak256, toHex, zeroAddress, type Address, type Hex } from "viem";
2
+ import { launchLifecycleAbi, lifecycleAdapterAbi, lifecycleErc20Abi } from "./abi.js";
3
+ import { hashLaunchIdentity, hashLaunchPlan, LifecycleFundingKind, LifecyclePhase, LifecycleRewardMode, LifecycleTokenKind, LIFECYCLE_MAX_REWARD_ERC20_SUPPLY, LIFECYCLE_MAX_ERC404_SUPPLY, parseLaunchPlan, serializeLaunchPlan, type LaunchPlanV1 } from "./schema.js";
4
+ import { readLaunchProgress, readLifecycleProfiles, readLifecycleTokenAtBlock, readPoolBoundHookDeployment } from "./progress.js";
5
+ import { decodeAbyssLifecycleMarketConfig, decodePoolBoundV4LifecycleMarketConfig, decodeV4LifecycleMarketConfig, encodePoolBoundV4LifecycleMarketConfig, isPoolBoundV4ConfigVersion, minePoolBoundHookSalt, type V4LifecycleMarketConfig, type V4PoolBoundLifecycleMarketConfig } from "./markets.js";
6
+ import { assertLifecycleBlock, assertLifecycleReadClientOpen, lifecycleFailureReason, lifecyclePinnedRpc, lifecycleRpc, lifecycleStage, readLifecycleBlock, readLifecycleChainId, readLifecycleContract, resolveLifecycleLimits, rpcHex, rpcQuantity, withLifecycleReadClient } from "./rpc.js";
7
+ import { prepareLaunchSimulation, simulateLaunchTransactions, type NextTransactionEnvelope } from "./simulation.js";
8
+ 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";
9
+ import { deriveKnownPoolBoundHookDeployment, protectOrderedBuyMinimums, validateBuySlippageBps } from "./calibration.js";
10
+ import { getKnownLifecycleDeployment, getKnownLifecycleProfile } from "./presets.js";
11
+ import { buildLaunchTransactions } from "./transactions.js";
12
+ import { assertLifecycleMarketAllocation, assertLifecycleSupplyAllocation } from "./allocations.js";
9
13
 
10
- function validatePlanShape(plan: LaunchPlanV1): void {
14
+ function validatePlanShape(plan: LaunchPlanV1): Hex {
11
15
  // Encoding checks all exact integer widths, addresses, byte strings and tuple fields.
12
- hashLaunchPlan(plan);
16
+ const commitment = hashLaunchPlan(plan);
13
17
  if (plan.markets.length === 0 || plan.markets.length > 16 || plan.buys.length > 64 || plan.funding.length > 8 || plan.feeAssets.length === 0 || plan.feeAssets.length > 8 || plan.token.supply <= 0n || plan.token.inventoryRecipient === zeroAddress || plan.token.inventoryRecipient.toLowerCase() === plan.orchestrator.toLowerCase() || plan.executorFeeBps > 1000) throw new LifecyclePlanningError("INVALID_PLAN", "Plan exceeds lifecycle bounds or lacks positive token economics");
14
18
  if ((plan.token.kind === LifecycleTokenKind.ERC404 && plan.token.supply > LIFECYCLE_MAX_ERC404_SUPPLY) || (plan.token.kind === LifecycleTokenKind.ERC20 && plan.token.rewardMode !== LifecycleRewardMode.None && plan.token.supply > LIFECYCLE_MAX_REWARD_ERC20_SUPPLY)) throw new LifecyclePlanningError("INVALID_TOKEN_SUPPLY", "Reward-enabled ERC20 supply must not exceed 10^77; ERC404 supply must fit uint96; reward-free ERC20 retains uint256");
15
19
  const encoder = new TextEncoder();
@@ -27,7 +31,6 @@ function validatePlanShape(plan: LaunchPlanV1): void {
27
31
  if (funding.kind === LifecycleFundingKind.Swap && (funding.inputAsset.toLowerCase() === funding.asset.toLowerCase() || funding.data === "0x" || (funding.data.length - 2) / 2 > 16384)) throw new LifecyclePlanningError("INVALID_FUNDING", "Conversion funding must change assets and commit bounded nonempty calldata");
28
32
  previous = BigInt(funding.asset);
29
33
  }
30
- if (plan.markets.reduce((sum, market) => sum + market.tokenBudget, 0n) > plan.token.supply) throw new LifecyclePlanningError("INVALID_TOKEN_BUDGET", "Market token budgets exceed committed supply");
31
34
  for (const buy of plan.buys) {
32
35
  if (buy.marketIndex >= plan.markets.length || buy.quoteAmountIn <= 0n || buy.minTokenOut <= 0n || buy.recipient === zeroAddress || buy.recipient.toLowerCase() === plan.orchestrator.toLowerCase()) throw new LifecyclePlanningError("INVALID_BUY", "Every ordered buy requires a committed market, positive budget/minimum, and external recipient");
33
36
  }
@@ -36,214 +39,143 @@ function validatePlanShape(plan: LaunchPlanV1): void {
36
39
  for (const market of plan.markets) {
37
40
  if (market.tokenBudget <= 0n || market.config === "0x" || (market.config.length - 2) / 2 > 16384) throw new LifecyclePlanningError("INVALID_MARKET", "Each market needs a positive token budget and bounded versioned config");
38
41
  let config: V4LifecycleMarketConfig | V4PoolBoundLifecycleMarketConfig | undefined;
39
- if (market.configVersion === 2 || market.configVersion === 3) throw new LifecyclePlanningError("UNSUPPORTED_CONFIG_VERSION", "Old V4 config versions 2 and 3 are not supported; encode reviewed terms explicitly using version 4 or 5");
42
+ if (market.configVersion !== 1 && market.configVersion !== 4 && !isPoolBoundV4ConfigVersion(market.configVersion)) throw new LifecyclePlanningError("UNSUPPORTED_CONFIG_VERSION", "Encode reviewed SharedV4 version 4 or pool-bound version 5/6 only on its exact matching deployment");
40
43
  if (market.configVersion === 4) config = decodeV4LifecycleMarketConfig(market.config);
41
- else if (market.configVersion === 5) config = decodePoolBoundV4LifecycleMarketConfig(market.config);
44
+ else if (isPoolBoundV4ConfigVersion(market.configVersion)) config = decodePoolBoundV4LifecycleMarketConfig(market.config, market.configVersion);
42
45
  if (config !== undefined) {
43
46
  if (config.profileId.toLowerCase() !== market.profileId.toLowerCase()) throw new LifecyclePlanningError("PROFILE_TERMS_MISMATCH", "Inner config profile differs from the committed market profile");
44
47
  const quote = market.quoteAsset.toLowerCase();
45
48
  if (v4Quotes.has(quote)) throw new LifecyclePlanningError("DUPLICATE_V4_QUOTE", "Only one V4 market per quote asset is allowed across shared and pool-bound offerings; put multiple positions in that market");
46
49
  v4Quotes.add(quote);
47
- if (config.positions.length === 0 || config.positions.length > 32 || config.positions.reduce((total, position) => total + position.maxTokenAmount, 0n) > market.tokenBudget || config.lpFeePips >= 1000000 || config.hookFeePips >= 1000000 || config.feeMode > 1 || config.tickSpacing < 1 || config.tickSpacing > 32767 || config.sqrtPriceX96 < 4295128739n || config.sqrtPriceX96 >= 1461446703485210103287273052203988822378723970342n || (config.protocolFeeDenominator !== 0 && (config.protocolFeeDenominator < 4 || config.protocolFeeDenominator > 10 || config.treasury === zeroAddress))) throw new LifecyclePlanningError("INVALID_MARKET", "V4 market violates lifecycle fee, price, treasury, position or budget bounds");
48
- positionCount += config.positions.length;
50
+ if (config.lpFeePips >= 1000000 || (config.version === 6 ? config.hookFeePips > 1000000 : config.hookFeePips >= 1000000) || config.feeMode > 1 || config.tickSpacing < 1 || config.tickSpacing > 32767 || config.sqrtPriceX96 < 4295128739n || config.sqrtPriceX96 >= 1461446703485210103287273052203988822378723970342n || (config.protocolFeeDenominator !== 0 && (config.protocolFeeDenominator < 4 || config.protocolFeeDenominator > 10 || config.treasury === zeroAddress))) throw new LifecyclePlanningError("INVALID_MARKET", "V4 market violates lifecycle fee, price, treasury or position bounds");
51
+ positionCount += assertLifecycleMarketAllocation(market, config.positions);
49
52
  }
53
+ else positionCount += assertLifecycleMarketAllocation(market, decodeAbyssLifecycleMarketConfig(market.config).positions);
50
54
  if (positionCount > 32) throw new LifecyclePlanningError("INVALID_POSITION_COUNT", "A launch commits at most 32 total positions across every venue");
51
55
  }
56
+ assertLifecycleSupplyAllocation(plan);
52
57
  for (const market of plan.markets) {
53
58
  const required = plan.buys.filter((buy) => plan.markets[buy.marketIndex]?.quoteAsset.toLowerCase() === market.quoteAsset.toLowerCase()).reduce((sum, buy) => sum + buy.quoteAmountIn, 0n);
54
59
  const funding = plan.funding.find((item) => item.asset.toLowerCase() === market.quoteAsset.toLowerCase());
55
60
  if (required > (funding?.amount ?? 0n)) throw new LifecyclePlanningError("INVALID_FUNDING", "Quote funding does not cover all ordered buys for its asset");
56
61
  }
62
+ return commitment;
57
63
  }
58
64
 
59
- async function readFunding(client: LifecycleRpcClient, plan: LaunchPlanV1, block: LifecycleBlock): Promise<{ prerequisites: LifecycleFundingPrerequisite[]; approvals: LifecycleTransaction[]; reasons: string[]; nativeValue: bigint }> {
60
- const [escrow, registry, balanceValue] = await Promise.all([
61
- readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "fundingEscrow", [], block),
62
- readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "registry", [], block),
63
- lifecyclePinnedRpc(client, "eth_getBalance", [plan.creator, toHex(block.number)], block),
64
- ]);
65
- const nativeBalance = rpcQuantity(balanceValue, "creator native balance");
66
- const [authority, observations] = await Promise.all([
67
- readLifecycleContract<Address>(client, escrow, lifecycleFundingEscrowAbi, "core", [], block),
68
- Promise.all(plan.funding.map(async (funding) => {
69
- const native = funding.kind === LifecycleFundingKind.NativeWrap || (funding.kind === LifecycleFundingKind.Swap && funding.inputAsset === zeroAddress);
70
- const [input, wrapped, target] = await Promise.all([
71
- native ? [nativeBalance, undefined] as const : Promise.all([
72
- readLifecycleContract<bigint>(client, funding.inputAsset, lifecycleErc20Abi, "balanceOf", [plan.creator], block),
73
- readLifecycleContract<bigint>(client, funding.inputAsset, lifecycleErc20Abi, "allowance", [plan.creator, escrow], block),
74
- ]),
75
- funding.kind === LifecycleFundingKind.NativeWrap ? readLifecycleContract<Address>(client, escrow, lifecycleFundingEscrowAbi, "wrappedNative", [], block) : undefined,
76
- funding.kind === LifecycleFundingKind.Swap ? Promise.all([
77
- readLifecycleContract<readonly [Address, Hex, boolean]>(client, registry, lifecycleRegistryAbi, "fundingTarget", [funding.target], block),
78
- lifecyclePinnedRpc(client, "eth_getCode", [funding.target, toHex(block.number)], block),
79
- ]) : undefined,
65
+ async function readFunding(client: LifecycleRpcClient, plan: LaunchPlanV1, block: LifecycleBlock): Promise<{ prerequisites: LifecycleFundingPrerequisite[]; approvals: LifecycleTransaction[] }> {
66
+ if (plan.funding.length === 0) return { prerequisites: [], approvals: [] };
67
+ const preset = getKnownLifecycleDeployment({ chainId: plan.chainId, orchestrator: plan.orchestrator });
68
+ const escrow = preset?.fundingEscrow ?? await readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "fundingEscrow", [], block);
69
+ // Only allowance observations construct approval commands. Core execution
70
+ // enforces input/output asset, funding target, balance and conversion predicates.
71
+ const prerequisites = await Promise.all(plan.funding.map(async (funding): Promise<LifecycleFundingPrerequisite> => {
72
+ const native = funding.kind === LifecycleFundingKind.NativeWrap || funding.kind === LifecycleFundingKind.Swap && funding.inputAsset === zeroAddress;
73
+ const [inputBalance, allowance] = native
74
+ ? [rpcQuantity(await lifecyclePinnedRpc(client, "eth_getBalance", [plan.creator, toHex(block.number)], block), "creator native balance"), undefined] as const
75
+ : await Promise.all([
76
+ readLifecycleContract<bigint>(client, funding.inputAsset, lifecycleErc20Abi, "balanceOf", [plan.creator], block),
77
+ readLifecycleContract<bigint>(client, funding.inputAsset, lifecycleErc20Abi, "allowance", [plan.creator, escrow], block),
80
78
  ]);
81
- let reason: string | undefined;
82
- if (wrapped !== undefined && (wrapped === zeroAddress || wrapped.toLowerCase() !== funding.asset.toLowerCase())) reason = "Committed native-wrap output is not the escrow's supported wrapped native asset";
83
- if (target !== undefined) {
84
- const [[spender, codeHash, enabled], codeValue] = target;
85
- const code = rpcHex(codeValue, "funding target code");
86
- if (!enabled || spender === zeroAddress || code === "0x" || keccak256(code).toLowerCase() !== codeHash.toLowerCase()) reason = "Committed funding conversion target is not currently allowlisted with its immutable code hash";
87
- }
88
- const prerequisite: LifecycleFundingPrerequisite = { funding, inputBalance: input[0], requiredInput: funding.inputAmount, allowance: input[1], spender: escrow, nativeValue: native ? funding.inputAmount : 0n, conversion: funding.kind === LifecycleFundingKind.NativeWrap ? "native-wrap" : funding.kind === LifecycleFundingKind.Swap ? "allowlisted-swap" : "none" };
89
- return { prerequisite, reason };
90
- })),
91
- ]);
92
- if (authority.toLowerCase() !== plan.orchestrator.toLowerCase()) throw new LifecyclePlanningError("FUNDING_BINDING", "Funding escrow authority differs from the committed orchestrator");
93
- const totals = new Map<string, { asset: Address; amount: bigint; balance: bigint; allowance: bigint }>();
94
- const prerequisites: LifecycleFundingPrerequisite[] = [];
95
- const approvals: LifecycleTransaction[] = [];
96
- const reasons: string[] = [];
97
- let nativeValue = 0n;
98
- for (const { prerequisite, reason } of observations) {
99
- prerequisites.push(prerequisite);
100
- if (reason !== undefined) reasons.push(reason);
101
- nativeValue += prerequisite.nativeValue;
102
- if (prerequisite.allowance !== undefined) {
103
- const { funding } = prerequisite;
104
- const key = funding.inputAsset.toLowerCase();
105
- const total = totals.get(key) ?? { asset: funding.inputAsset, amount: 0n, balance: prerequisite.inputBalance, allowance: prerequisite.allowance };
106
- total.amount += funding.inputAmount;
107
- totals.set(key, total);
108
- }
79
+ return { funding, inputBalance, requiredInput: funding.inputAmount, allowance, spender: escrow, nativeValue: native ? funding.inputAmount : 0n, conversion: funding.kind === LifecycleFundingKind.NativeWrap ? "native-wrap" : funding.kind === LifecycleFundingKind.Swap ? "allowlisted-swap" : "none" };
80
+ }));
81
+ const totals = new Map<string, { asset: Address; amount: bigint; allowance: bigint }>();
82
+ for (const prerequisite of prerequisites) {
83
+ if (prerequisite.allowance === undefined) continue;
84
+ const { funding } = prerequisite;
85
+ const key = funding.inputAsset.toLowerCase();
86
+ const total = totals.get(key) ?? { asset: funding.inputAsset, amount: 0n, allowance: prerequisite.allowance };
87
+ total.amount += funding.inputAmount;
88
+ totals.set(key, total);
109
89
  }
110
- if (nativeBalance < nativeValue) reasons.push("Creator native balance does not cover committed native funding inputs");
90
+ const approvals: LifecycleTransaction[] = [];
111
91
  for (const total of totals.values()) {
112
- if (total.balance < total.amount) reasons.push(`Creator balance is below combined funding requirements for ${total.asset}`);
113
92
  if (total.allowance >= total.amount) continue;
114
93
  if (total.allowance > 0n) approvals.push({ id: `approve-reset:${total.asset}`, kind: "approve-reset", chainId: Number(plan.chainId), from: plan.creator, to: total.asset, data: encodeFunctionData({ abi: lifecycleErc20Abi, functionName: "approve", args: [escrow, 0n] }), value: 0n, dependencies: [], postconditions: [{ kind: "allowance", asset: total.asset, spender: escrow, minimum: 0n, exact: 0n }] });
115
94
  approvals.push({ id: `approve:${total.asset}`, kind: "approve", chainId: Number(plan.chainId), from: plan.creator, to: total.asset, data: encodeFunctionData({ abi: lifecycleErc20Abi, functionName: "approve", args: [escrow, total.amount] }), value: 0n, dependencies: [], postconditions: [{ kind: "allowance", asset: total.asset, spender: escrow, minimum: total.amount }] });
116
95
  }
117
- return { prerequisites, approvals, reasons, nativeValue };
96
+ return { prerequisites, approvals };
118
97
  }
119
98
 
120
- async function validatePendingInputs(client: LifecycleRpcClient, planned: PlannedLaunch, progress: CanonicalLaunchProgress, block: LifecycleBlock): Promise<{ profiles: LifecycleProfile[]; hookDeployments: LifecyclePoolBoundHookDeployment[]; reasons: string[] }> {
121
- const { plan } = planned;
122
- const assets = new Set<Address>();
123
- for (const policy of plan.feeAssets) if (policy.asset.toLowerCase() !== planned.predictedToken.toLowerCase()) assets.add(policy.asset);
124
- for (const market of plan.markets) assets.add(market.quoteAsset);
125
- for (const funding of plan.funding) { assets.add(funding.asset); if (funding.kind !== LifecycleFundingKind.NativeWrap && funding.inputAsset !== zeroAddress) assets.add(funding.inputAsset); }
126
- const registryRead = readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "registry", [], block);
127
- const [profiles, registry, assetCodes, allowedInputs, escrowBalances] = await Promise.all([
128
- readLifecycleProfiles({ client, orchestrator: plan.orchestrator, profileIds: [...new Set(plan.markets.map((market) => market.profileId))] }, block),
129
- registryRead,
130
- Promise.all([...assets].map(async (asset) => ({ asset, code: rpcHex(await lifecyclePinnedRpc(client, "eth_getCode", [asset, toHex(block.number)], block), "asset code") }))),
131
- registryRead.then((registry) => Promise.all(plan.funding.map((funding) => funding.kind === LifecycleFundingKind.Swap && funding.inputAsset !== zeroAddress
132
- ? readLifecycleContract<boolean>(client, registry, lifecycleRegistryAbi, "fundingInputAllowed", [funding.inputAsset], block) : true))),
133
- progress.canonical.phase === LifecyclePhase.None ? [] : Promise.all(plan.funding.map((funding) => readLifecycleContract<bigint>(client, plan.orchestrator, launchLifecycleAbi, "escrowBalance", [planned.launchId, funding.asset], block))),
134
- ]);
135
- const reasons = profiles.filter((profile) => !profile.admitted).map((profile) => profile.reason ?? "Implementation/profile is unavailable");
136
- if (block.timestamp > plan.deadline) reasons.push("The immutable launch deadline has expired; cancellation remains available");
137
- for (const policy of plan.feeAssets) if (policy.burnBps !== 0 && policy.asset.toLowerCase() !== planned.predictedToken.toLowerCase()) reasons.push("Only the launch token may have a burn fee share");
138
- if (!plan.feeAssets.some((policy) => policy.asset.toLowerCase() === planned.predictedToken.toLowerCase())) reasons.push("The launch token must have a committed fee policy");
139
- for (const { asset, code } of assetCodes) if (code === "0x") reasons.push(`Asset ${asset} has no contract code`);
140
- const required = LIFECYCLE_REQUIRED_CAPABILITIES | (plan.token.kind === LifecycleTokenKind.ERC404 ? LIFECYCLE_ERC404_CAPABILITY : 0n);
141
- const markets = await Promise.all(plan.markets.map(async (market, index) => {
142
- const profile = profiles.find((item) => item.id.toLowerCase() === market.profileId.toLowerCase());
143
- let positions = 0;
144
- let deployment: LifecyclePoolBoundHookDeployment | undefined;
145
- let identity: MarketIdentityV1 | undefined;
146
- let reason: string | undefined;
147
- if (profile === undefined || !profile.admitted) return { positions, deployment, identity, reason };
148
- try {
149
- if (market.adapterId.toLowerCase() !== profile.registration.adapterId.toLowerCase() || market.configVersion !== profile.topology.configVersion) throw new LifecyclePlanningError("INVALID_MARKET", "Market adapter or version differs from exact certified profile");
150
- if (market.quoteAsset.toLowerCase() === planned.predictedToken.toLowerCase() || !plan.feeAssets.some((policy) => policy.asset.toLowerCase() === market.quoteAsset.toLowerCase())) throw new LifecyclePlanningError("INVALID_MARKET", "Each distinct quote requires its committed fee policy");
151
- const schema = profile.registration.configSchema.toLowerCase();
152
- const config = schema === V4_LIFECYCLE_CONFIG_SCHEMA.toLowerCase() ? decodeV4LifecycleMarketConfig(market.config) : schema === V4_POOL_BOUND_LIFECYCLE_CONFIG_SCHEMA.toLowerCase() ? decodePoolBoundV4LifecycleMarketConfig(market.config) : schema === ABYSS_LIFECYCLE_CONFIG_SCHEMA.toLowerCase() ? decodeAbyssLifecycleMarketConfig(market.config) : undefined;
153
- if (config === undefined) throw new LifecyclePlanningError("UNSUPPORTED_SCHEMA", `Unsupported lifecycle config schema ${profile.registration.configSchema}`);
154
- if ("developerFeeBps" in config) {
155
- validateV4LifecycleMarket({ market, config, profile, token: planned.predictedToken });
156
- }
157
- if (config.positions.length === 0 || config.positions.length > 32) throw new LifecyclePlanningError("INVALID_POSITION_COUNT", "A launch commits at most 32 positive positions across every market/venue");
158
- positions = config.positions.length;
159
- const rewards = progress.canonical.rewards === zeroAddress && plan.token.rewardMode === LifecycleRewardMode.Dividends ? planned.predictedToken : progress.canonical.rewards;
160
- if ("treasury" in config && rewards !== zeroAddress && config.treasury.toLowerCase() === rewards.toLowerCase() && config.hookFeePips !== 0 && config.protocolFeeDenominator !== 0) throw new LifecyclePlanningError("INVALID_FEE_POLICY", "Hook protocol treasury cannot be the launch rewards contract");
161
- const capabilities = required | (positions > 1 ? LIFECYCLE_MULTI_POSITION_CAPABILITY : 0n);
162
- const [adapter, boundDeployment, resolvedIdentity] = await Promise.all([
163
- readLifecycleContract<Address>(client, registry, lifecycleRegistryAbi, "requireEligible", [market.adapterId, market.profileId, market.configVersion, capabilities], block),
164
- profile.topology.hookTopology === 2 ? readPoolBoundHookDeployment({ client, plan, marketIndex: index }, block) : undefined,
165
- readLifecycleContract<MarketIdentityV1>(client, profile.adapter.implementation, lifecycleAdapterAbi, "resolve", [planned.launchId, planned.predictedToken, market], block),
166
- "developerFeeBps" in config && config.version === 4 ? validateV4LifecycleOracle({ client, envelope: profile.envelope!, oracleConfigId: config.oracleConfigId, block }) : undefined,
167
- ]);
168
- if (adapter.toLowerCase() !== profile.adapter.implementation.toLowerCase()) throw new LifecyclePlanningError("MARKET_IDENTITY", "Eligible adapter differs from the certified profile implementation");
169
- if (boundDeployment !== undefined) {
170
- if (!hasLifecycleV4HookPermissions(boundDeployment.predictedHook)) throw new LifecyclePlanningError("INVALID_HOOK_BITS", "Finalize the pool-bound salt with the exact lifecycle hook permission bits before planning wallet execution");
171
- deployment = { marketIndex: index, ...boundDeployment };
172
- }
173
- identity = resolvedIdentity;
174
- validateLifecycleMarketIdentity(identity, plan, planned.predictedToken, index);
175
- const expectedHook = deployment?.predictedHook ?? profile.registration.hook;
176
- if (identity.factory.toLowerCase() !== profile.registration.factory.toLowerCase() || identity.hook.toLowerCase() !== expectedHook.toLowerCase() || (identity.venue === LifecycleVenue.UniswapV4 && (identity.manager.toLowerCase() !== profile.registration.venue.toLowerCase() || profile.topology.hookTopology === 0)) || (identity.venue === LifecycleVenue.Abyss && (identity.factory.toLowerCase() !== profile.registration.venue.toLowerCase() || profile.topology.hookTopology !== 0))) throw new LifecyclePlanningError("MARKET_IDENTITY", "Canonical venue dependencies differ from exact immutable profile/topology approval");
177
- const prepared = progress.markets.find((item) => item.index === index);
178
- if (prepared !== undefined) {
179
- const resolvedHash = keccak256(encodeAbiParameters([{ type: "tuple", components: marketIdentityV1Components }], [identity]));
180
- const preparedHash = keccak256(encodeAbiParameters([{ type: "tuple", components: marketIdentityV1Components }], [prepared.prepared.identity]));
181
- if (resolvedHash !== preparedHash || prepared.live === undefined || prepared.live.publicTrading || prepared.live.liquidity !== 0n || prepared.live.sqrtPriceX96 !== identity.openingSqrtPriceX96) throw new LifecyclePlanningError("OPENING_CHANGED", "Prepared market is not at its committed empty canonical opening state");
182
- await readLifecycleContract<undefined>(client, adapter, lifecycleAdapterAbi, "validatePrepared", [planned.launchId, index, planned.predictedToken, market, prepared.prepared.identity], block);
183
- }
184
- } catch (failure) { reason = failure instanceof Error ? failure.message : String(failure); }
185
- return { positions, deployment, identity, reason };
186
- }));
187
- for (const [index, funding] of plan.funding.entries()) {
188
- if (funding.asset.toLowerCase() === planned.predictedToken.toLowerCase() || (funding.kind === LifecycleFundingKind.Swap && funding.inputAsset.toLowerCase() === planned.predictedToken.toLowerCase())) reasons.push("Launch-token inventory cannot be an external funding output or conversion input");
189
- if (!allowedInputs[index]) reasons.push(`Conversion input ${funding.inputAsset} is not currently admitted`);
190
- }
191
- const hookDeployments: LifecyclePoolBoundHookDeployment[] = [];
192
- const identities = new Set<string>();
193
- const v4Quotes = new Set<string>();
194
- let positionCount = 0;
195
- for (const [index, result] of markets.entries()) {
196
- if (positionCount + result.positions > 32) { reasons.push("A launch commits at most 32 positive positions across every market/venue"); continue; }
197
- positionCount += result.positions;
198
- if (result.deployment !== undefined) hookDeployments.push(result.deployment);
199
- if (result.reason !== undefined) { reasons.push(result.reason); continue; }
200
- if (result.identity === undefined) continue;
201
- if (result.identity.venue === LifecycleVenue.UniswapV4) {
202
- const quote = plan.markets[index]!.quoteAsset.toLowerCase();
203
- if (v4Quotes.has(quote)) { reasons.push("Only one V4 market per quote asset is allowed, regardless of adapter, manager, profile or salt"); continue; }
204
- v4Quotes.add(quote);
99
+ async function readPlanMetadata(client: LifecycleRpcClient, plan: LaunchPlanV1, token: Address, block: LifecycleBlock): Promise<{ profiles: (LifecycleProfile | LifecycleConstructionProfile)[]; hookDeployments: LifecyclePoolBoundHookDeployment[]; reasons: string[] }> {
100
+ const ids = [...new Set(plan.markets.map((market) => market.profileId.toLowerCase() as Hex))];
101
+ const known = ids.map((profileId) => getKnownLifecycleProfile({ chainId: plan.chainId, orchestrator: plan.orchestrator, profileId }));
102
+ const unknownIds = ids.filter((_id, index) => known[index] === undefined);
103
+ const live = unknownIds.length === 0 ? [] : await readLifecycleProfiles({ client, orchestrator: plan.orchestrator, profileIds: unknownIds }, block);
104
+ const profiles = ids.map((id, index): LifecycleProfile | LifecycleConstructionProfile => {
105
+ const preset = known[index];
106
+ if (preset !== undefined) {
107
+ if (!preset.supported) throw new LifecyclePlanningError("UNSUPPORTED_CONFIG_VERSION", preset.unavailableReason ?? "Historical preset does not support the current launch ABI");
108
+ return { ...preset.profile, metadataSource: "preset" };
205
109
  }
206
- const identity = result.identity.canonicalId.toLowerCase();
207
- if (identities.has(identity)) { reasons.push("Two committed markets resolve to the same canonical pool"); continue; }
208
- identities.add(identity);
209
- }
210
- for (const [index, balance] of escrowBalances.entries()) {
211
- const funding = plan.funding[index]!;
212
- const needed = plan.buys.filter((buy) => plan.markets[buy.marketIndex]?.quoteAsset.toLowerCase() === funding.asset.toLowerCase()).reduce((sum, buy) => sum + buy.quoteAmountIn, 0n);
213
- if (balance < needed) reasons.push(`Launch-isolated ${funding.asset} escrow cannot cover every committed buy`);
214
- }
215
- return { profiles, hookDeployments, reasons };
110
+ const profile = live.find((candidate) => candidate.id.toLowerCase() === id);
111
+ if (profile === undefined) throw new LifecyclePlanningError("PROFILE_NOT_FOUND", "Unlisted profile discovery omitted the committed profile");
112
+ return profile;
113
+ });
114
+ const hookDeployments = (await Promise.all(plan.markets.map(async (market, marketIndex): Promise<LifecyclePoolBoundHookDeployment | undefined> => {
115
+ if (!isPoolBoundV4ConfigVersion(market.configVersion)) return undefined;
116
+ const known = deriveKnownPoolBoundHookDeployment({ plan, token, marketIndex });
117
+ return { marketIndex, ...(known?.deployment ?? await readPoolBoundHookDeployment({ client, plan, marketIndex }, block)) };
118
+ }))).filter((deployment): deployment is LifecyclePoolBoundHookDeployment => deployment !== undefined);
119
+ return { profiles, hookDeployments, reasons: live.filter((profile) => !profile.admitted).map((profile) => profile.reason ?? "Unlisted lifecycle profile is unavailable") };
216
120
  }
217
121
 
218
- function orderedTransactions(plan: LaunchPlanV1, mode: "atomic" | "staged", progress: CanonicalLaunchProgress, approvals: readonly LifecycleTransaction[], nativeValue: bigint, batchSize: number): LifecycleTransaction[] {
219
- const transactions: LifecycleTransaction[] = [];
220
- if (progress.canonical.phase === LifecyclePhase.Active || progress.canonical.phase === LifecyclePhase.Cancelled) return transactions;
221
- const base = { chainId: Number(plan.chainId), from: plan.creator, to: plan.orchestrator, dependencies: [] as string[] };
222
- if (progress.canonical.phase === LifecyclePhase.None) {
223
- transactions.push(...approvals);
224
- if (mode === "atomic") {
225
- transactions.push({ ...base, id: "atomic", kind: "atomic", data: encodeFunctionData({ abi: launchLifecycleAbi, functionName: "launchAtomic", args: [plan] }), value: nativeValue, postconditions: [{ kind: "launch", phase: "Active", preparedMarkets: plan.markets.length }] });
226
- } else {
227
- transactions.push({ ...base, id: "begin", kind: "begin", data: encodeFunctionData({ abi: launchLifecycleAbi, functionName: "beginLaunch", args: [plan, LifecycleMode.Staged] }), value: nativeValue, postconditions: [{ kind: "launch", phase: "Preparing", preparedMarkets: 0 }] });
228
- }
229
- }
230
- if (mode === "staged") {
231
- for (let first = progress.canonical.preparedMarkets; first < plan.markets.length; first += batchSize) {
232
- const count = Math.min(batchSize, plan.markets.length - first);
233
- const completed = first + count;
234
- transactions.push({ ...base, id: `prepare:${first}:${count}`, kind: "prepare", data: encodeFunctionData({ abi: launchLifecycleAbi, functionName: "prepareMarkets", args: [plan, first, count] }), value: 0n, marketStart: first, marketCount: count, postconditions: [{ kind: "launch", phase: completed === plan.markets.length ? "Ready" : "Preparing", preparedMarkets: completed }] });
235
- }
236
- transactions.push({ ...base, id: "activate", kind: "activate", data: encodeFunctionData({ abi: launchLifecycleAbi, functionName: "activateLaunch", args: [plan] }), value: 0n, postconditions: [{ kind: "launch", phase: "Active", preparedMarkets: plan.markets.length }] });
237
- }
238
- return transactions.map((transaction, index) => ({ ...transaction, dependencies: index === 0 ? [] : [transactions[index - 1]?.id ?? ""] }));
122
+ function orderedTransactions(plan: LaunchPlanV1, mode: "atomic" | "staged", progress: CanonicalLaunchProgress, approvals: readonly LifecycleTransaction[], batchSize: number): LifecycleTransaction[] {
123
+ if (progress.canonical.phase === LifecyclePhase.Active || progress.canonical.phase === LifecyclePhase.Cancelled) return [];
124
+ const prepared = progress.canonical.preparedMarkets;
125
+ const batches: number[] = prepared > 0 ? [prepared] : [];
126
+ for (let first = prepared; first < plan.markets.length; first += batchSize) batches.push(Math.min(batchSize, plan.markets.length - first));
127
+ const commands = buildLaunchTransactions({ plan, mode, preparationBatches: mode === "staged" ? batches : undefined }).filter((transaction) =>
128
+ (transaction.kind !== "begin" && transaction.kind !== "atomic" || progress.canonical.phase === LifecyclePhase.None) &&
129
+ (transaction.kind !== "prepare" || (transaction.marketStart ?? 0) >= prepared));
130
+ const transactions = progress.canonical.phase === LifecyclePhase.None ? [...approvals, ...commands] : commands;
131
+ return transactions.map((transaction, index) => ({ ...transaction, calldataBytes: (transaction.data.length - 2) / 2, dependencies: index === 0 ? [] : [transactions[index - 1]!.id] }));
239
132
  }
240
133
 
241
134
  function attachEstimates(transactions: readonly LifecycleTransaction[], simulation: LifecycleSimulation): LifecycleTransaction[] {
242
135
  return transactions.map((transaction) => {
243
136
  const estimate = simulation.steps.find((step) => step.transactionId === transaction.id)?.estimate;
244
- return { ...transaction, gas: estimate?.gasLimit, gasPrice: estimate?.gasPrice, estimate, admission: { 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 } };
137
+ return { ...transaction, calldataBytes: (transaction.data.length - 2) / 2, gas: estimate?.gasLimit, gasPrice: estimate?.gasPrice, estimate, admission: { 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 } };
245
138
  });
246
139
  }
140
+ type ReviewedNextTransaction = Pick<LifecycleTransaction, "id" | "kind" | "chainId" | "from" | "to" | "data" | "value"> & NextTransactionEnvelope;
141
+ const reviewedTransactionKinds = ["approve-reset", "approve", "atomic", "begin", "prepare", "activate", "cancel"] as const;
142
+
143
+ function readReviewedNextTransaction(transaction: LifecycleTransaction | undefined): ReviewedNextTransaction | undefined {
144
+ if (transaction === undefined) return undefined;
145
+ if (transaction === null || typeof transaction !== "object" || typeof transaction.id !== "string" ||
146
+ !reviewedTransactionKinds.includes(transaction.kind) ||
147
+ !Number.isSafeInteger(transaction.chainId) || transaction.chainId <= 0 ||
148
+ typeof transaction.from !== "string" || !/^0x[\da-fA-F]{40}$/.test(transaction.from) ||
149
+ typeof transaction.to !== "string" || !/^0x[\da-fA-F]{40}$/.test(transaction.to) ||
150
+ typeof transaction.data !== "string" || !/^0x(?:[\da-fA-F]{2})*$/.test(transaction.data) ||
151
+ typeof transaction.value !== "bigint" || transaction.value < 0n || transaction.value >= 1n << 256n ||
152
+ typeof transaction.gas !== "bigint" || transaction.gas <= 0n || transaction.gas >= 1n << 64n ||
153
+ typeof transaction.gasPrice !== "bigint" || transaction.gasPrice < 0n || transaction.gasPrice >= 1n << 256n) {
154
+ 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");
155
+ }
156
+ return transaction as ReviewedNextTransaction;
157
+ }
158
+
159
+ function matchesReviewedNextTransaction(next: LifecycleTransaction | ReviewedNextTransaction | undefined, reviewed: ReviewedNextTransaction): boolean {
160
+ return next !== undefined && next.id === reviewed.id && next.kind === reviewed.kind && next.chainId === reviewed.chainId &&
161
+ next.from.toLowerCase() === reviewed.from.toLowerCase() && next.to.toLowerCase() === reviewed.to.toLowerCase() &&
162
+ next.data.toLowerCase() === reviewed.data.toLowerCase() && next.value === reviewed.value;
163
+ }
164
+
165
+ function assertReviewedTransactionUnchanged(options: BuildNextTransactionOptions, reviewed: ReviewedNextTransaction | undefined, next: LifecycleTransaction): void {
166
+ if (options.planned.account.toLowerCase() !== next.from.toLowerCase() || options.planned.plan.creator.toLowerCase() !== next.from.toLowerCase()) {
167
+ throw new LifecyclePlanningError("ACCOUNT_MISMATCH", "Execution account changed during fresh execution or submission proof");
168
+ }
169
+ if (options.planned.chainId !== BigInt(next.chainId) || options.planned.plan.chainId !== BigInt(next.chainId)) {
170
+ throw new LifecyclePlanningError("CHAIN_MISMATCH", "Execution chain changed during fresh execution or submission proof");
171
+ }
172
+ const current = readReviewedNextTransaction(options.reviewedTransaction);
173
+ if (reviewed === undefined ? current !== undefined : current === undefined || !matchesReviewedNextTransaction(current, reviewed) ||
174
+ current.gas !== reviewed.gas || current.gasPrice !== reviewed.gasPrice || !matchesReviewedNextTransaction(next, reviewed) ||
175
+ next.gas !== reviewed.gas || next.gasPrice !== reviewed.gasPrice) {
176
+ throw new LifecyclePlanningError("REVIEWED_TRANSACTION_CHANGED", "Reviewed transaction changed during fresh execution or submission proof");
177
+ }
178
+ }
247
179
 
248
180
  async function readTokenFactoryBinding(client: LifecycleRpcClient, orchestrator: Address, block: LifecycleBlock): Promise<Pick<PlannedLaunch, "tokenFactory" | "tokenFactoryCodeHash">> {
249
181
  const tokenFactory = await readLifecycleContract<Address>(client, orchestrator, launchLifecycleAbi, "tokenFactory", [], block);
@@ -266,163 +198,209 @@ function assertPlannedDeploymentBindings(stored: PlannedLaunch, current: Planned
266
198
  })) throw new LifecyclePlanningError("HOOK_DEPLOYMENT_CHANGED", "Final bound-hook deployer, initcode, salt or prediction changed before wallet execution");
267
199
  }
268
200
 
269
- /** Mines only certified bound markets, then re-reads the exact final economics and derived identities. */
270
- export async function preparePoolBoundLifecyclePlan(options: {
271
- client: LifecycleRpcClient; plan: LaunchPlanV1; signal?: AbortSignal;
272
- onProgress?: (progress: PoolBoundLifecyclePreparationProgress) => void;
273
- }): Promise<{ plan: LaunchPlanV1; deployments: readonly LifecyclePoolBoundHookDeployment[] }> {
274
- const { client, signal } = options;
275
- if (signal?.aborted) throw Object.assign(new Error("Pool-bound lifecycle preparation cancelled"), { name: "AbortError" });
276
- validatePlanShape(options.plan);
277
- const commitment = hashLaunchPlan(options.plan);
278
- let plan = parseLaunchPlan(serializeLaunchPlan(options.plan));
279
- const initialBlock = await readLifecycleBlock(client);
280
- if (rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID") !== plan.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain differs from the economic plan being finalized");
281
- const initialFactory = await readTokenFactoryBinding(client, plan.orchestrator, initialBlock);
282
- const initialToken = await readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "predictToken", [plan], initialBlock);
283
- const profiles = await readLifecycleProfiles({ client, orchestrator: plan.orchestrator, profileIds: [...new Set(plan.markets.map((market) => market.profileId))] }, initialBlock);
284
- const deployments: LifecyclePoolBoundHookDeployment[] = [];
285
- for (let marketIndex = 0; marketIndex < plan.markets.length; marketIndex += 1) {
286
- if (signal?.aborted) throw Object.assign(new Error("Pool-bound lifecycle preparation cancelled"), { name: "AbortError" });
287
- const market = plan.markets[marketIndex];
288
- if (market === undefined) throw new LifecyclePlanningError("INVALID_PLAN", "Market missing from ordered plan");
289
- const profile = profiles.find((item) => item.id.toLowerCase() === market.profileId.toLowerCase());
290
- if (profile === undefined || !profile.admitted) throw new LifecyclePlanningError("INELIGIBLE_PROFILE", profile?.reason ?? "Market is not an admitted lifecycle profile");
291
- if (profile.topology.hookTopology !== 2) continue;
292
- const metadata = await readPoolBoundHookDeployment({ client, plan, marketIndex }, initialBlock);
293
- const mined = await minePoolBoundHookSalt({
294
- deployer: metadata.deployer, initCodeHash: metadata.initCodeHash, startSalt: BigInt(metadata.salt), signal,
295
- onProgress: (progress) => options.onProgress?.({ marketIndex, ...progress }),
296
- });
297
- plan = { ...plan, markets: plan.markets.map((item, index) => index === marketIndex ? { ...item, config: encodePoolBoundV4LifecycleMarketConfig({ ...decodePoolBoundV4LifecycleMarketConfig(item.config), hookSalt: mined.salt }) } : item) };
298
- deployments.push({ marketIndex, deployer: metadata.deployer, initCodeHash: metadata.initCodeHash, ...mined });
299
- }
300
- await assertLifecycleBlock(client, initialBlock);
301
- const finalBlock = await readLifecycleBlock(client);
302
- const finalFactory = await readTokenFactoryBinding(client, plan.orchestrator, finalBlock);
303
- if (finalFactory.tokenFactory.toLowerCase() !== initialFactory.tokenFactory.toLowerCase() || finalFactory.tokenFactoryCodeHash.toLowerCase() !== initialFactory.tokenFactoryCodeHash.toLowerCase()) throw new LifecyclePlanningError("TOKEN_FACTORY_BINDING", "Token factory address or runtime code changed while mining the market-bound deployment");
304
- const finalToken = await readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "predictToken", [plan], finalBlock);
305
- if (finalToken.toLowerCase() !== initialToken.toLowerCase()) throw new LifecyclePlanningError("TOKEN_IDENTITY", "Token prediction changed while mining the market-bound deployment");
306
- const finalProfiles = await readLifecycleProfiles({ client, orchestrator: plan.orchestrator, profileIds: [...new Set(deployments.map((deployment) => plan.markets[deployment.marketIndex]?.profileId).filter((id): id is Hex => id !== undefined))] }, finalBlock);
307
- for (const deployment of deployments) {
308
- if (signal?.aborted) throw Object.assign(new Error("Pool-bound lifecycle preparation cancelled"), { name: "AbortError" });
309
- const market = plan.markets[deployment.marketIndex];
310
- const profile = finalProfiles.find((item) => item.id.toLowerCase() === market?.profileId.toLowerCase());
311
- if (market === undefined || profile === undefined || !profile.admitted || profile.topology.hookTopology !== 2) throw new LifecyclePlanningError("UNCERTIFIED_TOPOLOGY", profile?.reason ?? "Final bound profile is no longer admitted");
312
- const fresh = await readPoolBoundHookDeployment({ client, plan, marketIndex: deployment.marketIndex }, finalBlock);
313
- if (fresh.deployer.toLowerCase() !== deployment.deployer.toLowerCase() || fresh.initCodeHash.toLowerCase() !== deployment.initCodeHash.toLowerCase() || fresh.salt.toLowerCase() !== deployment.salt.toLowerCase() || fresh.predictedHook.toLowerCase() !== deployment.predictedHook.toLowerCase() || !hasLifecycleV4HookPermissions(fresh.predictedHook) || predictPoolBoundHookAddress(fresh).toLowerCase() !== fresh.predictedHook.toLowerCase()) throw new LifecyclePlanningError("HOOK_DEPLOYMENT_CHANGED", "Mined deployment no longer matches the exact final economic configuration and certified factory");
314
- const identity = await readLifecycleContract<MarketIdentityV1>(client, profile.adapter.implementation, lifecycleAdapterAbi, "resolve", [hashLaunchIdentity(plan), finalToken, market], finalBlock);
315
- validateLifecycleMarketIdentity(identity, plan, finalToken, deployment.marketIndex);
316
- if (identity.hook.toLowerCase() !== fresh.predictedHook.toLowerCase() || identity.manager.toLowerCase() !== profile.registration.venue.toLowerCase()) throw new LifecyclePlanningError("HOOK_DEPLOYMENT_CHANGED", "Final resolved PoolKey does not use the exact mined hook/manager");
317
- }
318
- if (hashLaunchPlan(options.plan).toLowerCase() !== commitment.toLowerCase()) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed during asynchronous salt mining; previous mining is invalid");
319
- if (signal?.aborted) throw Object.assign(new Error("Pool-bound lifecycle preparation cancelled"), { name: "AbortError" });
320
- await assertLifecycleBlock(client, finalBlock);
321
- return { plan, deployments };
201
+ function assertPlanningActive(signal?: AbortSignal): void {
202
+ if (signal?.aborted) throw Object.assign(new Error("Lifecycle launch planning cancelled"), { name: "AbortError" });
322
203
  }
323
204
 
324
- /** Exact economic commitment with explicit execution mode and current-state admission. No broadcasts. */
205
+ function validatePlanningIntent(options: PlanLaunchOptions): Hex {
206
+ const { plan, account, mode } = options;
207
+ assertPlanningActive(options.signal);
208
+ if (mode !== "atomic" && mode !== "staged") throw new LifecyclePlanningError("EXPLICIT_MODE_REQUIRED", "Choose atomic or staged explicitly; staged is never silently substituted");
209
+ if (account.toLowerCase() !== plan.creator.toLowerCase()) throw new LifecyclePlanningError("ACCOUNT_MISMATCH", "Execution account must be the committed creator/payer/refund account");
210
+ if (plan.chainId > BigInt(Number.MAX_SAFE_INTEGER) || plan.chainId <= 0n) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Chain ID must be a positive exact JavaScript integer");
211
+ return validatePlanShape(plan);
212
+ }
213
+
214
+ function assertFinalPlan(planned: PlannedLaunch, signal?: AbortSignal): void {
215
+ assertPlanningActive(signal);
216
+ if (hashLaunchPlan(planned.plan).toLowerCase() !== planned.planHash.toLowerCase() || hashLaunchIdentity(planned.plan).toLowerCase() !== planned.launchId.toLowerCase()) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed during asynchronous planning; previous reads and simulation are invalid");
217
+ }
218
+
219
+ /** One owner constructs and mines, optionally measures actual ordered buy outputs,
220
+ * then proves only the final protected commitment. No certificates or skip flags. */
221
+ export async function prepareAndPlanLifecycleLaunch(options: PrepareAndPlanLifecycleLaunchOptions): Promise<PlannedLaunch> {
222
+ const commitment = validatePlanningIntent(options);
223
+ if (options.buySlippageBps !== undefined) validateBuySlippageBps(options.buySlippageBps);
224
+ const needsMining = options.plan.markets.some((market) => isPoolBoundV4ConfigVersion(market.configVersion));
225
+ const needsProtection = options.buySlippageBps !== undefined && options.plan.buys.length !== 0;
226
+ if (!needsMining && !needsProtection) return planLaunch(options);
227
+ const draft = parseLaunchPlan(serializeLaunchPlan(options.plan));
228
+ const assertDraft = () => {
229
+ assertPlanningActive(options.signal);
230
+ if (hashLaunchPlan(options.plan).toLowerCase() !== commitment.toLowerCase()) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed during asynchronous preparation; previous construction is invalid");
231
+ };
232
+ return withLifecycleReadClient(options.client, async (client) => {
233
+ const [block, chainId] = await Promise.all([readLifecycleBlock(client), readLifecycleChainId(client)]);
234
+ if (chainId !== draft.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain differs from the economic plan being finalized");
235
+ const metadata = needsMining ? await Promise.all(draft.markets.map((market, marketIndex) => isPoolBoundV4ConfigVersion(market.configVersion)
236
+ ? readPoolBoundHookDeployment({ client, plan: draft, marketIndex }, block) : undefined)) : [];
237
+ assertDraft();
238
+ const markets = [...draft.markets];
239
+ const minedDeployments: LifecyclePoolBoundHookDeployment[] = [];
240
+ for (const [marketIndex, deployment] of metadata.entries()) {
241
+ if (deployment === undefined) continue;
242
+ assertDraft();
243
+ const mined = await minePoolBoundHookSalt({
244
+ deployer: deployment.deployer, initCodeHash: deployment.initCodeHash, startSalt: BigInt(deployment.salt), signal: options.signal,
245
+ onProgress: options.onProgress === undefined ? undefined : (progress) => options.onProgress?.({ marketIndex, ...progress }),
246
+ });
247
+ const market = markets[marketIndex]!;
248
+ if (!isPoolBoundV4ConfigVersion(market.configVersion)) throw new LifecyclePlanningError("INVALID_BOUND_MARKET", "Mined market lost its explicit pool-bound version");
249
+ markets[marketIndex] = { ...market, config: encodePoolBoundV4LifecycleMarketConfig({ ...decodePoolBoundV4LifecycleMarketConfig(market.config, market.configVersion), hookSalt: mined.salt }) };
250
+ minedDeployments.push({ marketIndex, deployer: deployment.deployer, initCodeHash: deployment.initCodeHash, ...mined });
251
+ }
252
+ assertDraft();
253
+ // Mining is local construction, not a state admission. Economic observations
254
+ // start at a fresh post-mining snapshot and share it through final protection.
255
+ const executionBlock = needsMining ? await readLifecycleBlock(client) : block;
256
+ assertDraft();
257
+ let plan: LaunchPlanV1 = { ...draft, markets };
258
+ let ownedProgress: CanonicalLaunchProgress | undefined;
259
+ if (needsProtection) {
260
+ // A diagnostic minimum is never returned as an admitted or executable plan.
261
+ // Price guards, funding minima, recipients and all ordered budgets stay exact.
262
+ const diagnosticPlan = { ...plan, buys: plan.buys.map((buy) => ({ ...buy, minTokenOut: 1n })) };
263
+ const diagnostic = await lifecycleStage(client, "plan", () => planLaunchWithReads({ ...options, plan: diagnosticPlan }, client, executionBlock, undefined, true));
264
+ assertDraft();
265
+ 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");
266
+ plan = protectOrderedBuyMinimums(diagnostic, options.buySlippageBps!);
267
+ // This is the real SDK-owned None observation, not an assumed new-launch
268
+ // state. No onchain commitment existed at this exact snapshot to replace.
269
+ ownedProgress = { ...diagnostic.progress, planHash: hashLaunchPlan(plan) };
270
+ }
271
+ const planned = await lifecycleStage(client, "plan", () => planLaunchWithReads({ ...options, plan }, client, executionBlock, ownedProgress));
272
+ assertDraft();
273
+ assertFinalPlan(planned, options.signal);
274
+ if (planned.progress.canonical.phase !== LifecyclePhase.Active && planned.progress.canonical.phase !== LifecyclePhase.Cancelled) {
275
+ for (const mined of minedDeployments) {
276
+ const final = planned.hookDeployments.find((deployment) => deployment.marketIndex === mined.marketIndex);
277
+ if (final === undefined || final.deployer.toLowerCase() !== mined.deployer.toLowerCase() || final.initCodeHash.toLowerCase() !== mined.initCodeHash.toLowerCase() || final.salt.toLowerCase() !== mined.salt.toLowerCase() || final.predictedHook.toLowerCase() !== mined.predictedHook.toLowerCase()) throw new LifecyclePlanningError("HOOK_DEPLOYMENT_CHANGED", "Final exact constructor binding differs from the one locally mined candidate");
278
+ }
279
+ }
280
+ 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);
281
+ return planned;
282
+ });
283
+ }
284
+
285
+ /** Independently fresh current-state admission of an already finalized commitment. No broadcasts. */
325
286
  export async function planLaunch(options: PlanLaunchOptions): Promise<PlannedLaunch> {
326
287
  return withLifecycleReadClient(options.client, async (client) => {
327
288
  const planned = await lifecycleStage(client, "plan", () => planLaunchWithReads(options, client));
328
- if (hashLaunchPlan(planned.plan).toLowerCase() !== planned.planHash.toLowerCase() || hashLaunchIdentity(planned.plan).toLowerCase() !== planned.launchId.toLowerCase()) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed during asynchronous planning; previous reads and simulation are invalid");
289
+ assertFinalPlan(planned, options.signal);
329
290
  return planned;
330
291
  });
331
292
  }
332
293
 
333
- async function planLaunchWithReads(options: PlanLaunchOptions, client: LifecycleRpcClient): Promise<PlannedLaunch> {
294
+ async function planLaunchWithReads(options: PlanLaunchOptions, client: LifecycleRpcClient, pinnedBlock?: LifecycleBlock, ownedProgress?: CanonicalLaunchProgress, diagnostic = false, reviewed?: ReviewedNextTransaction): Promise<PlannedLaunch> {
334
295
  const { plan, account, mode } = options;
335
- if (mode !== "atomic" && mode !== "staged") throw new LifecyclePlanningError("EXPLICIT_MODE_REQUIRED", "Choose atomic or staged explicitly; staged is never silently substituted");
336
- if (account.toLowerCase() !== plan.creator.toLowerCase()) throw new LifecyclePlanningError("ACCOUNT_MISMATCH", "Execution account must be the committed creator/payer/refund account");
337
- if (plan.chainId > BigInt(Number.MAX_SAFE_INTEGER) || plan.chainId <= 0n) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Chain ID must be a positive exact JavaScript integer");
338
- validatePlanShape(plan);
339
- const planHash = hashLaunchPlan(plan); const launchId = hashLaunchIdentity(plan);
340
- const [block, chainIdValue] = await lifecycleStage(client, "plan.context", () => Promise.all([
341
- readLifecycleBlock(client),
342
- lifecycleRpc(client, "eth_chainId"),
296
+ const planHash = validatePlanningIntent(options);
297
+ const launchId = hashLaunchIdentity(plan);
298
+ const [block, chainId] = await lifecycleStage(client, "plan.context", () => Promise.all([
299
+ pinnedBlock ?? readLifecycleBlock(client), readLifecycleChainId(client),
343
300
  ]));
344
- if (rpcQuantity(chainIdValue, "chain ID") !== plan.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain differs from the committed plan");
345
- const prediction = readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "predictToken", [plan], block);
346
- const [remoteHash, remoteIdentity, { tokenFactory, tokenFactoryCodeHash }, { limits }, { identity, progress }] = await lifecycleStage(client, "plan.domain", () => Promise.all([
347
- readLifecycleContract<Hex>(client, plan.orchestrator, launchLifecycleAbi, "hashPlan", [plan], block),
348
- readLifecycleContract<Hex>(client, plan.orchestrator, launchLifecycleAbi, "launchIdOf", [plan], block),
349
- readTokenFactoryBinding(client, plan.orchestrator, block),
301
+ if (chainId !== plan.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain differs from the committed plan");
302
+ // Only preparation/build-next can supply freshly observed progress from this
303
+ // still-open SDK invocation. Stored/query DTOs never enter this continuation.
304
+ if (ownedProgress !== undefined) {
305
+ if (!assertLifecycleReadClientOpen(client) || pinnedBlock !== block || ownedProgress.blockNumber !== block.number ||
306
+ ownedProgress.blockHash.toLowerCase() !== block.hash.toLowerCase() || ownedProgress.chainId !== plan.chainId ||
307
+ ownedProgress.confirmationDepth !== (options.confirmations ?? 1)) throw new LifecyclePlanningError("INVALID_READ_CONTEXT", "Owned progress differs from its active planning snapshot");
308
+ if (ownedProgress.planHash.toLowerCase() !== planHash.toLowerCase() || ownedProgress.launchId.toLowerCase() !== launchId.toLowerCase()) throw new LifecyclePlanningError("PLAN_MUTATED", "Economic plan changed after its canonical progress was read");
309
+ }
310
+ assertPlanningActive(options.signal);
311
+ const preset = getKnownLifecycleDeployment({ chainId: plan.chainId, orchestrator: plan.orchestrator });
312
+ const [{ tokenFactory, tokenFactoryCodeHash }, { limits }, { identity, progress }] = await lifecycleStage(client, "plan.domain", () => Promise.all([
313
+ preset ?? readTokenFactoryBinding(client, plan.orchestrator, block),
350
314
  resolveLifecycleLimits({ client, account, orchestrator: plan.orchestrator, block, chainId: plan.chainId }, options.limits),
351
315
  lifecycleStage(client, "plan.progress", async () => {
352
- const predictedToken = await prediction;
316
+ const predictedToken = await readLifecycleTokenAtBlock(client, plan, block);
353
317
  const identity = { plan, planHash, launchId, predictedToken, account, mode, confirmations: options.confirmations ?? 1 };
354
- const progress = await readLaunchProgress({ client, planned: identity, receipts: options.receipts }, block);
318
+ const progress = ownedProgress ?? await readLaunchProgress({ client, planned: identity, receipts: options.receipts, signal: options.signal }, block);
319
+ if (ownedProgress !== undefined && progress.token.toLowerCase() !== predictedToken.toLowerCase()) throw new LifecyclePlanningError("TOKEN_IDENTITY", "Predicted token changed after its canonical progress was read");
355
320
  return { identity, progress };
356
321
  }),
357
322
  ]));
358
- if (remoteHash.toLowerCase() !== planHash.toLowerCase() || remoteIdentity.toLowerCase() !== launchId.toLowerCase()) throw new LifecyclePlanningError("ABI_DOMAIN_MISMATCH", "Selected contract does not implement the exact lifecycle V1 plan domain/schema");
323
+ assertPlanningActive(options.signal);
359
324
  const placeholder: LifecycleSimulation = { backend: "unavailable", confidence: "provisional", admitted: false, executionProof: "unavailable", protocolFit: "unknown", transportPreflight: "not-requested", blockNumber: block.number, blockHash: block.hash, account, chainId: plan.chainId, limits, steps: [] };
360
- let planned: PlannedLaunch = { ...identity, tokenFactory, tokenFactoryCodeHash, chainId: plan.chainId, transactions: [], prerequisites: [], profiles: [], hookDeployments: [], progress, simulation: placeholder, atomicAttempt: placeholder, preparationBatchSize: plan.markets.length, limits: options.limits };
325
+ let planned: PlannedLaunch = { ...identity, tokenFactory, tokenFactoryCodeHash, chainId: plan.chainId, transactions: [], totalCalldataBytes: 0, prerequisites: [], profiles: [], hookDeployments: [], progress, simulation: placeholder, preparationBatchSize: plan.markets.length, limits: options.limits };
361
326
  const receiptsPending = progress.receipts.some((receipt) => receipt.status === "pending" || receipt.status === "unconfirmed");
362
327
  if (!progress.confirmationSafe || receiptsPending) {
363
328
  const hookDeployments: LifecyclePoolBoundHookDeployment[] = [];
364
329
  for (let marketIndex = 0; marketIndex < plan.markets.length; marketIndex += 1) {
365
- if (plan.markets[marketIndex]?.configVersion === 5) hookDeployments.push({ marketIndex, ...await readPoolBoundHookDeployment({ client, plan, marketIndex }, block) });
330
+ const market = plan.markets[marketIndex]!;
331
+ if (!isPoolBoundV4ConfigVersion(market.configVersion)) continue;
332
+ const known = deriveKnownPoolBoundHookDeployment({ plan, token: identity.predictedToken, marketIndex });
333
+ hookDeployments.push({ marketIndex, ...(known?.deployment ?? await readPoolBoundHookDeployment({ client, plan, marketIndex }, block)) });
366
334
  }
367
- // Receipt/nonce gates never silently drop the finalized deployment baseline on reload.
368
335
  const waiting = { ...placeholder, confidence: "stateful" as const, reason: !progress.confirmationSafe ? "Canonical confirmed progress/account nonce has not caught up with head or pending transactions; wait for the selected confirmation depth" : "Wait for canonical receipt confirmations or resolve replacement before requesting another transaction" };
369
336
  await assertLifecycleBlock(client, block, plan.chainId);
370
- return { ...planned, hookDeployments, simulation: waiting, atomicAttempt: waiting };
337
+ return { ...planned, hookDeployments, simulation: waiting };
371
338
  }
372
339
  if (progress.canonical.phase === LifecyclePhase.Active || progress.canonical.phase === LifecyclePhase.Cancelled) {
373
340
  await assertLifecycleBlock(client, block, plan.chainId);
374
341
  return { ...planned, simulation: { ...placeholder, confidence: "stateful", executionProof: "proved", protocolFit: "proved", admitted: true, reason: "Canonical launch is terminal; no transaction remains" } };
375
342
  }
376
- // Only batching-capable sources overlap the first simulation's read-only
377
- // context with independent input admission. Waiting/terminal plans skip it.
378
- const preparedSimulation = lifecycleSourceClient(client).supportsReadBatching === true
379
- ? prepareLaunchSimulation({ client, planned, limits: options.limits, fork: options.fork }, block)
380
- : undefined;
381
- const [prerequisites, validation] = await lifecycleStage(client, "plan.inputs", () => Promise.all([
382
- progress.canonical.phase === LifecyclePhase.None ? readFunding(client, plan, block) : { prerequisites: [], approvals: [], nativeValue: 0n, reasons: [] },
383
- validatePendingInputs(client, planned, progress, block),
343
+ const [funding, metadata] = await lifecycleStage(client, "plan.inputs", () => Promise.all([
344
+ progress.canonical.phase === LifecyclePhase.None ? readFunding(client, plan, block) : { prerequisites: [], approvals: [] },
345
+ readPlanMetadata(client, plan, identity.predictedToken, block),
384
346
  ]));
385
- const reasons = [...prerequisites.reasons, ...validation.reasons];
386
- planned = { ...planned, prerequisites: prerequisites.prerequisites, profiles: validation.profiles, hookDeployments: validation.hookDeployments };
387
- if (reasons.length > 0) {
388
- const rejected = { ...placeholder, confidence: "stateful" as const, reason: reasons.join("; ") };
347
+ assertFinalPlan(planned, options.signal);
348
+ planned = { ...planned, prerequisites: funding.prerequisites, profiles: metadata.profiles, hookDeployments: metadata.hookDeployments };
349
+ let preparationBatchSize = plan.markets.length;
350
+ let transactions = orderedTransactions(plan, mode, progress, funding.approvals, preparationBatchSize);
351
+ if (metadata.reasons.length > 0) {
389
352
  await assertLifecycleBlock(client, block, plan.chainId);
390
- return { ...planned, simulation: rejected, atomicAttempt: rejected, transactions: orderedTransactions(plan, mode, progress, prerequisites.approvals, prerequisites.nativeValue, planned.preparationBatchSize) };
353
+ 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) };
391
354
  }
392
- let atomicAttempt: LifecycleSimulation = { ...placeholder, reason: "Atomic execution cannot be evaluated from an already begun staged launch" };
393
- if (progress.canonical.phase === LifecyclePhase.None) {
394
- const atomicTransactions = orderedTransactions(plan, "atomic", progress, prerequisites.approvals, prerequisites.nativeValue, plan.markets.length);
395
- atomicAttempt = await (preparedSimulation === undefined
396
- ? simulateLaunchTransactions({ client, planned, limits: options.limits, fork: options.fork }, atomicTransactions, block)
397
- : preparedSimulation(atomicTransactions));
398
- if (mode === "atomic") {
399
- const reason = atomicAttempt.admitted ? undefined : `${atomicAttempt.reason ?? "Exact atomic plan is not admitted"}; choose staged explicitly only if empty preparation can be partitioned and complete activation fits`;
400
- const simulation = { ...atomicAttempt, reason };
401
- return { ...planned, atomicAttempt, simulation, transactions: attachEstimates(atomicTransactions, simulation) };
355
+ const preparedSimulation = prepareLaunchSimulation({ client, planned, limits: options.limits, fork: options.fork, signal: options.signal }, block);
356
+ try {
357
+ const execute = diagnostic ? preparedSimulation.measure : (candidate: readonly LifecycleTransaction[]) => {
358
+ if (reviewed === undefined) return preparedSimulation(candidate);
359
+ if (!matchesReviewedNextTransaction(candidate[0], reviewed)) {
360
+ // A fresh prepare batch may still require SDK-owned capacity discovery.
361
+ // Never use held fees for a different step or caller-selected calldata.
362
+ if (candidate[0]?.kind === "prepare" && reviewed.kind === "prepare") return preparedSimulation(candidate);
363
+ throw new LifecyclePlanningError("REVIEWED_TRANSACTION_MISMATCH", "Reviewed transaction differs from the current canonical unfinished next step");
364
+ }
365
+ return preparedSimulation(candidate, reviewed);
366
+ };
367
+ let simulation = await execute(transactions);
368
+ assertFinalPlan(planned, options.signal);
369
+ if (mode === "staged") {
370
+ // Only explicit gas/compute evidence can partition empty preparations.
371
+ // Byte caps cannot shrink: each prepare still carries the entire plan.
372
+ while (!simulation.admitted && simulation.failureCategory === "capacity" &&
373
+ (simulation.capacityConstraint === "compute" || simulation.capacityConstraint === "gas-envelope") &&
374
+ !(reviewed !== undefined && simulation.capacityConstraint === "gas-envelope" && simulation.failedTransactionId === transactions[0]?.id && matchesReviewedNextTransaction(transactions[0], reviewed)) &&
375
+ simulation.failedTransactionId?.startsWith("prepare:") && preparationBatchSize > 1) {
376
+ const failedMarketCount = transactions.find((transaction) => transaction.id === simulation.failedTransactionId)?.marketCount ?? 0;
377
+ if (failedMarketCount <= 1) break;
378
+ preparationBatchSize = Math.ceil(failedMarketCount / 2);
379
+ transactions = orderedTransactions(plan, mode, progress, funding.approvals, preparationBatchSize);
380
+ simulation = await execute(transactions);
381
+ assertFinalPlan(planned, options.signal);
382
+ }
383
+ if (!simulation.admitted && simulation.failedTransactionId === "activate") simulation = { ...simulation, reason: `${simulation.reason ?? "Final activation failed"}; final mint/lock/all-buys/public-opening is indivisible and will not be partitioned` };
384
+ } else if (!diagnostic && !simulation.admitted) {
385
+ simulation = { ...simulation, reason: `${simulation.reason ?? "Exact atomic plan is not admitted"}; choose staged explicitly only if empty preparation can be partitioned and complete activation fits` };
402
386
  }
403
- }
404
- let preparationBatchSize = plan.markets.length;
405
- let transactions = orderedTransactions(plan, "staged", progress, prerequisites.approvals, prerequisites.nativeValue, preparationBatchSize);
406
- let simulation = await (preparedSimulation === undefined || progress.canonical.phase === LifecyclePhase.None
407
- ? simulateLaunchTransactions({ client, planned, limits: options.limits, fork: options.fork }, transactions, block)
408
- : preparedSimulation(transactions));
409
- while (!simulation.admitted && simulation.confidence === "stateful" && simulation.failedTransactionId?.startsWith("prepare:") && preparationBatchSize > 1) {
410
- preparationBatchSize = Math.ceil(preparationBatchSize / 2);
411
- transactions = orderedTransactions(plan, "staged", progress, prerequisites.approvals, prerequisites.nativeValue, preparationBatchSize);
412
- simulation = await simulateLaunchTransactions({ client, planned, limits: options.limits, fork: options.fork }, transactions, block);
413
- }
414
- if (!simulation.admitted && simulation.failedTransactionId === "activate") simulation = { ...simulation, reason: `${simulation.reason ?? "Final activation failed"}; final mint/lock/all-buys/public-opening is indivisible and will not be partitioned` };
415
- return { ...planned, atomicAttempt, simulation, preparationBatchSize, transactions: attachEstimates(transactions, simulation) };
387
+ if (reviewed !== undefined && !matchesReviewedNextTransaction(transactions[0], reviewed)) {
388
+ throw new LifecyclePlanningError("REVIEWED_TRANSACTION_MISMATCH", "Reviewed transaction differs from the current canonical unfinished next step");
389
+ }
390
+ return { ...planned, simulation, preparationBatchSize, transactions: attachEstimates(transactions, simulation), totalCalldataBytes: transactions.reduce((total, transaction) => total + (transaction.data.length - 2) / 2, 0) };
391
+ } finally { preparedSimulation.close(); }
416
392
  }
417
393
 
418
- async function preflightNextTransaction(options: BuildNextTransactionOptions, next: LifecycleTransaction, simulation: LifecycleSimulation): Promise<LifecycleTransaction> {
394
+ async function preflightNextTransaction(options: BuildNextTransactionOptions, next: LifecycleTransaction, simulation: LifecycleSimulation, reviewed?: ReviewedNextTransaction): Promise<LifecycleTransaction> {
419
395
  if (next.gas === undefined || next.gas <= 0n || next.gas >= 1n << 64n || next.gasPrice === undefined || next.gasPrice < 0n ||
420
396
  simulation.executionProof !== "proved" || simulation.protocolFit !== "proved" || !simulation.admitted ||
421
397
  next.estimate === undefined || next.estimate.gasLimit !== next.gas || next.estimate.gasPrice !== next.gasPrice ||
422
398
  simulation.limits.transactionGasCeiling !== undefined && next.gas > simulation.limits.transactionGasCeiling) {
423
399
  throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Next transaction has no current-state exact gas/headroom/fee-envelope proof", simulation);
424
400
  }
401
+ assertReviewedTransactionUnchanged(options, reviewed, next);
425
402
  const submissionClient = options.submissionClient;
403
+ assertPlanningActive(options.signal);
426
404
  if (submissionClient === undefined) return { ...next, dependencies: [] };
427
405
  try {
428
406
  if (rpcQuantity(await lifecycleRpc(submissionClient, "eth_chainId"), "submission chain ID") !== simulation.chainId) throw new Error("Active submission RPC chain differs from the reviewed transaction");
@@ -431,46 +409,64 @@ async function preflightNextTransaction(options: BuildNextTransactionOptions, ne
431
409
  }, "latest"]), "submission gas estimate");
432
410
  if (estimated <= 0n || estimated > next.gas) throw new Error("Submission RPC gas estimate exceeds the exact reviewed envelope or is zero");
433
411
  if (rpcQuantity(await lifecycleRpc(submissionClient, "eth_chainId"), "submission chain ID") !== simulation.chainId) throw new Error("Active submission RPC chain changed during preflight");
412
+ assertPlanningActive(options.signal);
434
413
  } catch (failure) {
435
- const reason = `Exact next-transaction submission preflight failed: ${failure instanceof Error ? failure.message : String(failure)}`;
414
+ if (failure instanceof Error && failure.name === "AbortError") throw failure;
415
+ const reason = `Exact next-transaction submission preflight failed: ${lifecycleFailureReason(failure)}`;
436
416
  throw new LifecyclePlanningError("SUBMISSION_PREFLIGHT_FAILED", reason, { ...simulation, transportPreflight: "failed", reason });
437
417
  }
438
- const canonical = await readLifecycleBlock(options.client, toHex(simulation.blockNumber));
418
+ const [canonical, chainId] = await Promise.all([
419
+ readLifecycleBlock(options.client, toHex(simulation.blockNumber)),
420
+ lifecycleRpc(options.client, "eth_chainId"),
421
+ ]);
439
422
  if (canonical.hash.toLowerCase() !== simulation.blockHash.toLowerCase()) throw new LifecyclePlanningError("STATE_REORGED", "Pinned launch proof changed during submission preflight; rebuild from canonical progress");
440
- if (rpcQuantity(await lifecycleRpc(options.client, "eth_chainId"), "chain ID") !== simulation.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Source RPC chain changed during submission preflight");
423
+ if (rpcQuantity(chainId, "chain ID") !== simulation.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Source RPC chain changed during submission preflight");
441
424
  if (next.admission === undefined) throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Next transaction omitted its exact simulation admission", simulation);
425
+ assertPlanningActive(options.signal);
426
+ assertReviewedTransactionUnchanged(options, reviewed, next);
442
427
  return { ...next, dependencies: [], admission: { ...next.admission, transportPreflight: "passed" } };
443
428
  }
444
429
 
445
- /** Re-reads canonical state, revalidates all prerequisites, and proves the remaining sequence afresh. */
430
+ /** Recovery observes real canonical receipts/nonces, then proves only unfinished
431
+ * execution; known construction metadata is not recertified through getter mirrors. */
446
432
  export async function buildNextTransaction(options: BuildNextTransactionOptions): Promise<LifecycleTransaction | undefined> {
447
- const block = await readLifecycleBlock(options.client);
448
- const progress = await readLaunchProgress(options, block);
449
- if (progress.canonical.phase === LifecyclePhase.Active || progress.canonical.phase === LifecyclePhase.Cancelled) {
450
- // Only the confirmed canonical terminal phase is terminal; a head-only
451
- // Active observation is not, and the confirmation/nonce/receipt gates below
452
- // remain in force for every nonterminal case.
453
- await assertLifecycleBlock(options.client, block);
454
- return undefined;
455
- }
456
- if (!progress.confirmationSafe) throw new LifecyclePlanningError("UNCONFIRMED_STATE", "Wait for confirmed canonical progress and account nonce, including untracked approvals/replacements, before constructing the next transaction");
457
- if (progress.receipts.some((receipt) => receipt.status === "pending" || receipt.status === "unconfirmed")) throw new LifecyclePlanningError("RECEIPT_PENDING", "Wait for canonical receipt confirmations or resolve replacement before requesting another transaction");
458
- if (options.action === "cancel") {
459
- if (progress.canonical.phase === LifecyclePhase.None) throw new LifecyclePlanningError("NOT_STARTED", "An unstarted plan has no launch escrow to cancel");
460
- const { planned } = options;
461
- const transaction: LifecycleTransaction = { 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 }] };
462
- const simulation = await simulateLaunchTransactions({ client: options.client, planned, limits: options.limits === undefined ? planned.limits : options.limits, fork: options.fork }, [transaction], block);
463
- if (!simulation.admitted) throw new LifecyclePlanningError("CANCEL_NOT_ADMITTED", simulation.reason ?? "Cancellation could not be proved against current canonical state", simulation);
464
- const next = attachEstimates([transaction], simulation)[0];
465
- if (next === undefined) throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Cancellation omitted its exact simulation admission", simulation);
466
- return preflightNextTransaction(options, next, simulation);
467
- }
468
- const refreshed = await planLaunch({ client: options.client, plan: options.planned.plan, account: options.planned.account, mode: options.planned.mode, limits: options.limits === undefined ? options.planned.limits : options.limits, fork: options.fork, receipts: options.receipts, confirmations: options.confirmations ?? options.planned.confirmations });
469
- if (!refreshed.simulation.admitted) throw new LifecyclePlanningError("PLAN_NOT_ADMITTED", refreshed.simulation.reason ?? "Remaining exact launch sequence is not admitted", refreshed.simulation);
470
- assertPlannedDeploymentBindings(options.planned, refreshed);
471
- const next = refreshed.transactions[0];
472
- if (next === undefined) return undefined;
473
- return preflightNextTransaction(options, next, refreshed.simulation);
433
+ const supplied = readReviewedNextTransaction(options.reviewedTransaction);
434
+ // Snapshot only the reviewed wire fields; caller proofs and commands are never
435
+ // reused, and final checks compare the mutable input with this one local copy.
436
+ const reviewed = supplied === undefined ? undefined : Object.freeze({ id: supplied.id, kind: supplied.kind, chainId: supplied.chainId,
437
+ from: supplied.from, to: supplied.to, data: supplied.data, value: supplied.value, gas: supplied.gas, gasPrice: supplied.gasPrice });
438
+ return withLifecycleReadClient(options.client, async (client) => {
439
+ const block = await readLifecycleBlock(client);
440
+ const progress = await readLaunchProgress({ ...options, client }, block);
441
+ if (progress.canonical.phase === LifecyclePhase.Active || progress.canonical.phase === LifecyclePhase.Cancelled) {
442
+ // Only the confirmed canonical terminal phase is terminal; a head-only
443
+ // Active observation is not, and the confirmation/nonce/receipt gates below
444
+ // remain in force for every nonterminal case.
445
+ await assertLifecycleBlock(client, block, options.planned.chainId);
446
+ return undefined;
447
+ }
448
+ if (!progress.confirmationSafe) throw new LifecyclePlanningError("UNCONFIRMED_STATE", "Wait for confirmed canonical progress and account nonce, including untracked approvals/replacements, before constructing the next transaction");
449
+ if (progress.receipts.some((receipt) => receipt.status === "pending" || receipt.status === "unconfirmed")) throw new LifecyclePlanningError("RECEIPT_PENDING", "Wait for canonical receipt confirmations or resolve replacement before requesting another transaction");
450
+ assertPlanningActive(options.signal);
451
+ if (options.action === "cancel") {
452
+ if (progress.canonical.phase === LifecyclePhase.None) throw new LifecyclePlanningError("NOT_STARTED", "An unstarted plan has no launch escrow to cancel");
453
+ const { planned } = options;
454
+ const transaction: LifecycleTransaction = { 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 }] };
455
+ if (reviewed !== undefined && !matchesReviewedNextTransaction(transaction, reviewed)) throw new LifecyclePlanningError("REVIEWED_TRANSACTION_MISMATCH", "Reviewed transaction differs from the current canonical cancellation");
456
+ const simulation = await simulateLaunchTransactions({ client, planned, limits: options.limits === undefined ? planned.limits : options.limits, fork: options.fork, signal: options.signal }, [transaction], block, reviewed);
457
+ if (!simulation.admitted) throw new LifecyclePlanningError("CANCEL_NOT_ADMITTED", simulation.reason ?? "Cancellation could not be proved against current canonical state", simulation);
458
+ const next = attachEstimates([transaction], simulation)[0];
459
+ if (next === undefined) throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Cancellation omitted its exact simulation admission", simulation);
460
+ return preflightNextTransaction(options, next, simulation, reviewed);
461
+ }
462
+ const refreshed = await lifecycleStage(client, "plan", () => planLaunchWithReads({ client, plan: options.planned.plan, account: options.planned.account, mode: options.planned.mode, limits: options.limits === undefined ? options.planned.limits : options.limits, fork: options.fork, receipts: options.receipts, confirmations: options.confirmations ?? options.planned.confirmations, signal: options.signal }, client, block, progress, false, reviewed));
463
+ assertFinalPlan(refreshed, options.signal);
464
+ if (!refreshed.simulation.admitted) throw new LifecyclePlanningError("PLAN_NOT_ADMITTED", refreshed.simulation.reason ?? "Remaining exact launch sequence is not admitted", refreshed.simulation);
465
+ assertPlannedDeploymentBindings(options.planned, refreshed);
466
+ const next = refreshed.transactions[0];
467
+ if (next === undefined) return undefined;
468
+ return preflightNextTransaction(options, next, refreshed.simulation, reviewed);
469
+ });
474
470
  }
475
471
 
476
472
  /** Re-simulates only canonical unfinished work with the same submitted receipt
@@ -481,7 +477,7 @@ export async function simulateLaunchPlan(options: SimulateLaunchPlanOptions): Pr
481
477
  const current = await planLaunch({
482
478
  client, plan: planned.plan, account: planned.account, mode: planned.mode,
483
479
  limits: options.limits === undefined ? planned.limits : options.limits, fork: options.fork,
484
- receipts: options.receipts, confirmations: options.confirmations ?? planned.confirmations,
480
+ receipts: options.receipts, confirmations: options.confirmations ?? planned.confirmations, signal: options.signal,
485
481
  });
486
482
  assertPlannedDeploymentBindings(planned, current);
487
483
  return current.simulation;