@black-market/sdk 0.5.2 → 0.6.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.
@@ -3,7 +3,7 @@ import { launchLifecycleAbi, lifecycleAdapterAbi, lifecycleErc20Abi, lifecycleFu
3
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
4
  import { ABYSS_LIFECYCLE_CONFIG_SCHEMA, readLaunchProgress, readLifecycleProfiles, readPoolBoundHookDeployment, validateLifecycleMarketIdentity, validateV4LifecycleOracle } from "./progress.js";
5
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, lifecycleRpc, readLifecycleBlock, readLifecycleContract, resolveLifecycleLimits, rpcHex, rpcQuantity } from "./rpc.js";
6
+ import { assertLifecycleBlock, lifecyclePinnedRpc, lifecycleRpc, readLifecycleBlock, readLifecycleContract, resolveLifecycleLimits, rpcHex, rpcQuantity, withLifecycleReadClient } from "./rpc.js";
7
7
  import { simulateLaunchTransactions } from "./simulation.js";
8
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";
9
9
 
@@ -57,42 +57,55 @@ function validatePlanShape(plan: LaunchPlanV1): void {
57
57
  }
58
58
 
59
59
  async function readFunding(client: LifecycleRpcClient, plan: LaunchPlanV1, block: LifecycleBlock): Promise<{ prerequisites: LifecycleFundingPrerequisite[]; approvals: LifecycleTransaction[]; reasons: string[]; nativeValue: bigint }> {
60
- const escrow = await readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "fundingEscrow", [], block);
61
- if ((await readLifecycleContract<Address>(client, escrow, lifecycleFundingEscrowAbi, "core", [], block)).toLowerCase() !== plan.orchestrator.toLowerCase()) throw new LifecyclePlanningError("FUNDING_BINDING", "Funding escrow authority differs from the committed orchestrator");
62
- const registry = await readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "registry", [], block);
63
- const nativeBalance = rpcQuantity(await lifecycleRpc(client, "eth_getBalance", [plan.creator, toHex(block.number)]), "creator native balance");
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,
80
+ ]);
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");
64
93
  const totals = new Map<string, { asset: Address; amount: bigint; balance: bigint; allowance: bigint }>();
65
94
  const prerequisites: LifecycleFundingPrerequisite[] = [];
66
95
  const approvals: LifecycleTransaction[] = [];
67
96
  const reasons: string[] = [];
68
97
  let nativeValue = 0n;
69
- for (const funding of plan.funding) {
70
- const native = funding.kind === LifecycleFundingKind.NativeWrap || (funding.kind === LifecycleFundingKind.Swap && funding.inputAsset === zeroAddress);
71
- let inputBalance = nativeBalance;
72
- let allowance: bigint | undefined;
73
- if (native) nativeValue += funding.inputAmount;
74
- else {
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;
75
104
  const key = funding.inputAsset.toLowerCase();
76
- let total = totals.get(key);
77
- if (total === undefined) {
78
- inputBalance = await readLifecycleContract<bigint>(client, funding.inputAsset, lifecycleErc20Abi, "balanceOf", [plan.creator], block);
79
- allowance = await readLifecycleContract<bigint>(client, funding.inputAsset, lifecycleErc20Abi, "allowance", [plan.creator, escrow], block);
80
- total = { asset: funding.inputAsset, amount: 0n, balance: inputBalance, allowance };
81
- totals.set(key, total);
82
- }
105
+ const total = totals.get(key) ?? { asset: funding.inputAsset, amount: 0n, balance: prerequisite.inputBalance, allowance: prerequisite.allowance };
83
106
  total.amount += funding.inputAmount;
84
- inputBalance = total.balance; allowance = total.allowance;
85
- }
86
- if (funding.kind === LifecycleFundingKind.NativeWrap) {
87
- const wrapped = await readLifecycleContract<Address>(client, escrow, lifecycleFundingEscrowAbi, "wrappedNative", [], block);
88
- if (wrapped === zeroAddress || wrapped.toLowerCase() !== funding.asset.toLowerCase()) reasons.push("Committed native-wrap output is not the escrow's supported wrapped native asset");
89
- }
90
- if (funding.kind === LifecycleFundingKind.Swap) {
91
- const [spender, codeHash, enabled] = await readLifecycleContract<readonly [Address, Hex, boolean]>(client, registry, lifecycleRegistryAbi, "fundingTarget", [funding.target], block);
92
- const code = rpcHex(await lifecycleRpc(client, "eth_getCode", [funding.target, toHex(block.number)]), "funding target code");
93
- if (!enabled || spender === zeroAddress || code === "0x" || keccak256(code).toLowerCase() !== codeHash.toLowerCase()) reasons.push("Committed funding conversion target is not currently allowlisted with its immutable code hash");
107
+ totals.set(key, total);
94
108
  }
95
- prerequisites.push({ 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" });
96
109
  }
97
110
  if (nativeBalance < nativeValue) reasons.push("Creator native balance does not cover committed native funding inputs");
98
111
  for (const total of totals.values()) {
@@ -106,79 +119,98 @@ async function readFunding(client: LifecycleRpcClient, plan: LaunchPlanV1, block
106
119
 
107
120
  async function validatePendingInputs(client: LifecycleRpcClient, planned: PlannedLaunch, progress: CanonicalLaunchProgress, block: LifecycleBlock): Promise<{ profiles: LifecycleProfile[]; hookDeployments: LifecyclePoolBoundHookDeployment[]; reasons: string[] }> {
108
121
  const { plan } = planned;
109
- const profiles = await readLifecycleProfiles({ client, orchestrator: plan.orchestrator, profileIds: [...new Set(plan.markets.map((market) => market.profileId))] }, block);
110
- const registry = await readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "registry", [], block);
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 [profiles, registry, assetCodes] = await Promise.all([
127
+ readLifecycleProfiles({ client, orchestrator: plan.orchestrator, profileIds: [...new Set(plan.markets.map((market) => market.profileId))] }, block),
128
+ readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "registry", [], block),
129
+ Promise.all([...assets].map(async (asset) => ({ asset, code: rpcHex(await lifecyclePinnedRpc(client, "eth_getCode", [asset, toHex(block.number)], block), "asset code") }))),
130
+ ]);
111
131
  const reasons = profiles.filter((profile) => !profile.admitted).map((profile) => profile.reason ?? "Implementation/profile is unavailable");
112
132
  if (block.timestamp > plan.deadline) reasons.push("The immutable launch deadline has expired; cancellation remains available");
113
133
  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");
114
134
  if (!plan.feeAssets.some((policy) => policy.asset.toLowerCase() === planned.predictedToken.toLowerCase())) reasons.push("The launch token must have a committed fee policy");
115
- const hookDeployments: LifecyclePoolBoundHookDeployment[] = [];
116
- const assets = new Set< Address >();
117
- for (const policy of plan.feeAssets) if (policy.asset.toLowerCase() !== planned.predictedToken.toLowerCase()) assets.add(policy.asset);
118
- for (const market of plan.markets) assets.add(market.quoteAsset);
119
- for (const funding of plan.funding) { assets.add(funding.asset); if (funding.kind !== LifecycleFundingKind.NativeWrap && funding.inputAsset !== zeroAddress) assets.add(funding.inputAsset); }
120
- for (const asset of assets) if (rpcHex(await lifecycleRpc(client, "eth_getCode", [asset, toHex(block.number)]), "asset code") === "0x") reasons.push(`Asset ${asset} has no contract code`);
121
- for (const funding of plan.funding) {
135
+ for (const { asset, code } of assetCodes) if (code === "0x") reasons.push(`Asset ${asset} has no contract code`);
136
+ const required = LIFECYCLE_REQUIRED_CAPABILITIES | (plan.token.kind === LifecycleTokenKind.ERC404 ? LIFECYCLE_ERC404_CAPABILITY : 0n);
137
+ const [allowedInputs, markets, escrowBalances] = await Promise.all([
138
+ Promise.all(plan.funding.map((funding) => funding.kind === LifecycleFundingKind.Swap && funding.inputAsset !== zeroAddress
139
+ ? readLifecycleContract<boolean>(client, registry, lifecycleRegistryAbi, "fundingInputAllowed", [funding.inputAsset], block) : true)),
140
+ Promise.all(plan.markets.map(async (market, index) => {
141
+ const profile = profiles.find((item) => item.id.toLowerCase() === market.profileId.toLowerCase());
142
+ let positions = 0;
143
+ let deployment: LifecyclePoolBoundHookDeployment | undefined;
144
+ let identity: MarketIdentityV1 | undefined;
145
+ let reason: string | undefined;
146
+ if (profile === undefined || !profile.admitted) return { positions, deployment, identity, reason };
147
+ try {
148
+ 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");
149
+ 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");
150
+ const schema = profile.registration.configSchema.toLowerCase();
151
+ 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;
152
+ if (config === undefined) throw new LifecyclePlanningError("UNSUPPORTED_SCHEMA", `Unsupported lifecycle config schema ${profile.registration.configSchema}`);
153
+ if ("developerFeeBps" in config) {
154
+ validateV4LifecycleMarket({ market, config, profile, token: planned.predictedToken });
155
+ if (config.version === 4) await validateV4LifecycleOracle({ client, envelope: profile.envelope!, oracleConfigId: config.oracleConfigId, block });
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] = 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
+ ]);
166
+ if (adapter.toLowerCase() !== profile.adapter.implementation.toLowerCase()) throw new LifecyclePlanningError("MARKET_IDENTITY", "Eligible adapter differs from the certified profile implementation");
167
+ if (boundDeployment !== undefined) {
168
+ 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");
169
+ deployment = { marketIndex: index, ...boundDeployment };
170
+ }
171
+ identity = await readLifecycleContract<MarketIdentityV1>(client, adapter, lifecycleAdapterAbi, "resolve", [planned.launchId, planned.predictedToken, market], block);
172
+ validateLifecycleMarketIdentity(identity, plan, planned.predictedToken, index);
173
+ const expectedHook = deployment?.predictedHook ?? profile.registration.hook;
174
+ 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");
175
+ const prepared = progress.markets.find((item) => item.index === index);
176
+ if (prepared !== undefined) {
177
+ const resolvedHash = keccak256(encodeAbiParameters([{ type: "tuple", components: marketIdentityV1Components }], [identity]));
178
+ const preparedHash = keccak256(encodeAbiParameters([{ type: "tuple", components: marketIdentityV1Components }], [prepared.prepared.identity]));
179
+ 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");
180
+ await readLifecycleContract<undefined>(client, adapter, lifecycleAdapterAbi, "validatePrepared", [planned.launchId, index, planned.predictedToken, market, prepared.prepared.identity], block);
181
+ }
182
+ } catch (failure) { reason = failure instanceof Error ? failure.message : String(failure); }
183
+ return { positions, deployment, identity, reason };
184
+ })),
185
+ progress.canonical.phase === LifecyclePhase.None ? [] : Promise.all(plan.funding.map((funding) => readLifecycleContract<bigint>(client, plan.orchestrator, launchLifecycleAbi, "escrowBalance", [planned.launchId, funding.asset], block))),
186
+ ]);
187
+ for (const [index, funding] of plan.funding.entries()) {
122
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");
123
- if (funding.kind === LifecycleFundingKind.Swap && funding.inputAsset !== zeroAddress && !await readLifecycleContract<boolean>(client, registry, lifecycleRegistryAbi, "fundingInputAllowed", [funding.inputAsset], block)) reasons.push(`Conversion input ${funding.inputAsset} is not currently admitted`);
189
+ if (!allowedInputs[index]) reasons.push(`Conversion input ${funding.inputAsset} is not currently admitted`);
124
190
  }
125
- const required = LIFECYCLE_REQUIRED_CAPABILITIES | (plan.token.kind === LifecycleTokenKind.ERC404 ? LIFECYCLE_ERC404_CAPABILITY : 0n);
191
+ const hookDeployments: LifecyclePoolBoundHookDeployment[] = [];
126
192
  const identities = new Set<string>();
127
193
  const v4Quotes = new Set<string>();
128
194
  let positionCount = 0;
129
- for (let index = 0; index < plan.markets.length; index += 1) {
130
- const market = plan.markets[index];
131
- if (market === undefined) throw new LifecyclePlanningError("INVALID_PLAN", "Market missing from ordered plan");
132
- const profile = profiles.find((item) => item.id.toLowerCase() === market.profileId.toLowerCase());
133
- if (profile === undefined || !profile.admitted) continue;
134
- try {
135
- 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");
136
- 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");
137
- const schema = profile.registration.configSchema.toLowerCase();
138
- 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;
139
- if (config === undefined) throw new LifecyclePlanningError("UNSUPPORTED_SCHEMA", `Unsupported lifecycle config schema ${profile.registration.configSchema}`);
140
- if ("developerFeeBps" in config) {
141
- validateV4LifecycleMarket({ market, config, profile, token: planned.predictedToken });
142
- if (config.version === 4) await validateV4LifecycleOracle({ client, envelope: profile.envelope!, oracleConfigId: config.oracleConfigId, block });
143
- }
144
- if (config.positions.length === 0 || config.positions.length > 32 || positionCount + config.positions.length > 32) throw new LifecyclePlanningError("INVALID_POSITION_COUNT", "A launch commits at most 32 positive positions across every market/venue");
145
- positionCount += config.positions.length;
146
- const rewards = progress.canonical.rewards === zeroAddress && plan.token.rewardMode === LifecycleRewardMode.Dividends ? planned.predictedToken : progress.canonical.rewards;
147
- 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");
148
- const capabilities = required | (config.positions.length > 1 ? LIFECYCLE_MULTI_POSITION_CAPABILITY : 0n);
149
- const adapter = await readLifecycleContract<Address>(client, registry, lifecycleRegistryAbi, "requireEligible", [market.adapterId, market.profileId, market.configVersion, capabilities], block);
150
- if (adapter.toLowerCase() !== profile.adapter.implementation.toLowerCase()) throw new LifecyclePlanningError("MARKET_IDENTITY", "Eligible adapter differs from the certified profile implementation");
151
- const deployment = profile.topology.hookTopology === 2 ? await readPoolBoundHookDeployment({ client, plan, marketIndex: index }, block) : undefined;
152
- if (deployment !== undefined) {
153
- if (!hasLifecycleV4HookPermissions(deployment.predictedHook)) throw new LifecyclePlanningError("INVALID_HOOK_BITS", "Finalize the pool-bound salt with the exact lifecycle hook permission bits before planning wallet execution");
154
- hookDeployments.push({ marketIndex: index, ...deployment });
155
- }
156
- const identity = await readLifecycleContract<MarketIdentityV1>(client, adapter, lifecycleAdapterAbi, "resolve", [planned.launchId, planned.predictedToken, market], block);
157
- validateLifecycleMarketIdentity(identity, plan, planned.predictedToken, index);
158
- const expectedHook = deployment?.predictedHook ?? profile.registration.hook;
159
- 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");
160
- if (identity.venue === LifecycleVenue.UniswapV4) {
161
- const quote = market.quoteAsset.toLowerCase();
162
- if (v4Quotes.has(quote)) throw new LifecyclePlanningError("DUPLICATE_V4_QUOTE", "Only one V4 market per quote asset is allowed, regardless of adapter, manager, profile or salt");
163
- v4Quotes.add(quote);
164
- }
165
- if (identities.has(identity.canonicalId.toLowerCase())) throw new LifecyclePlanningError("DUPLICATE_MARKET", "Two committed markets resolve to the same canonical pool");
166
- identities.add(identity.canonicalId.toLowerCase());
167
- const prepared = progress.markets.find((item) => item.index === index);
168
- if (prepared !== undefined) {
169
- const resolvedHash = keccak256(encodeAbiParameters([{ type: "tuple", components: marketIdentityV1Components }], [identity]));
170
- const preparedHash = keccak256(encodeAbiParameters([{ type: "tuple", components: marketIdentityV1Components }], [prepared.prepared.identity]));
171
- 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");
172
- await readLifecycleContract<undefined>(client, adapter, lifecycleAdapterAbi, "validatePrepared", [planned.launchId, index, planned.predictedToken, market, prepared.prepared.identity], block);
173
- }
174
- } catch (failure) { reasons.push(failure instanceof Error ? failure.message : String(failure)); }
175
- }
176
- if (progress.canonical.phase !== LifecyclePhase.None) {
177
- for (const funding of plan.funding) {
178
- const balance = await readLifecycleContract<bigint>(client, plan.orchestrator, launchLifecycleAbi, "escrowBalance", [planned.launchId, funding.asset], block);
179
- const needed = plan.buys.filter((buy) => plan.markets[buy.marketIndex]?.quoteAsset.toLowerCase() === funding.asset.toLowerCase()).reduce((sum, buy) => sum + buy.quoteAmountIn, 0n);
180
- if (balance < needed) reasons.push(`Launch-isolated ${funding.asset} escrow cannot cover every committed buy`);
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);
181
205
  }
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`);
182
214
  }
183
215
  return { profiles, hookDeployments, reasons };
184
216
  }
@@ -209,14 +241,18 @@ function orderedTransactions(plan: LaunchPlanV1, mode: "atomic" | "staged", prog
209
241
  function attachEstimates(transactions: readonly LifecycleTransaction[], simulation: LifecycleSimulation): LifecycleTransaction[] {
210
242
  return transactions.map((transaction) => {
211
243
  const estimate = simulation.steps.find((step) => step.transactionId === transaction.id)?.estimate;
212
- return { ...transaction, gas: estimate?.gasLimit, gasPrice: estimate?.gasPrice, estimate, admission: { admitted: simulation.admitted, confidence: simulation.confidence, reason: simulation.reason, limits: simulation.limits } };
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 } };
213
245
  });
214
246
  }
215
247
 
216
248
  async function readTokenFactoryBinding(client: LifecycleRpcClient, orchestrator: Address, block: LifecycleBlock): Promise<Pick<PlannedLaunch, "tokenFactory" | "tokenFactoryCodeHash">> {
217
249
  const tokenFactory = await readLifecycleContract<Address>(client, orchestrator, launchLifecycleAbi, "tokenFactory", [], block);
218
- const code = rpcHex(await lifecycleRpc(client, "eth_getCode", [tokenFactory, toHex(block.number)]), "token factory code");
219
- if (tokenFactory === zeroAddress || code === "0x" || (await readLifecycleContract<Address>(client, tokenFactory, lifecycleAdapterAbi, "core", [], block)).toLowerCase() !== orchestrator.toLowerCase()) throw new LifecyclePlanningError("TOKEN_FACTORY_BINDING", "Token factory is not live and bound to the exact lifecycle core");
250
+ const [codeValue, authority] = await Promise.all([
251
+ lifecyclePinnedRpc(client, "eth_getCode", [tokenFactory, toHex(block.number)], block),
252
+ readLifecycleContract<Address>(client, tokenFactory, lifecycleAdapterAbi, "core", [], block),
253
+ ]);
254
+ const code = rpcHex(codeValue, "token factory code");
255
+ if (tokenFactory === zeroAddress || code === "0x" || authority.toLowerCase() !== orchestrator.toLowerCase()) throw new LifecyclePlanningError("TOKEN_FACTORY_BINDING", "Token factory is not live and bound to the exact lifecycle core");
220
256
  return { tokenFactory, tokenFactoryCodeHash: keccak256(code) };
221
257
  }
222
258
 
@@ -287,21 +323,34 @@ export async function preparePoolBoundLifecyclePlan(options: {
287
323
 
288
324
  /** Exact economic commitment with explicit execution mode and current-state admission. No broadcasts. */
289
325
  export async function planLaunch(options: PlanLaunchOptions): Promise<PlannedLaunch> {
290
- const { client, plan, account, mode } = options;
326
+ return withLifecycleReadClient(options.client, async (client) => {
327
+ const planned = await 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");
329
+ return planned;
330
+ });
331
+ }
332
+
333
+ async function planLaunchWithReads(options: PlanLaunchOptions, client: LifecycleRpcClient): Promise<PlannedLaunch> {
334
+ const { plan, account, mode } = options;
291
335
  if (mode !== "atomic" && mode !== "staged") throw new LifecyclePlanningError("EXPLICIT_MODE_REQUIRED", "Choose atomic or staged explicitly; staged is never silently substituted");
292
336
  if (account.toLowerCase() !== plan.creator.toLowerCase()) throw new LifecyclePlanningError("ACCOUNT_MISMATCH", "Execution account must be the committed creator/payer/refund account");
293
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");
294
338
  validatePlanShape(plan);
295
- const block = await readLifecycleBlock(client);
296
- if (rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID") !== plan.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain differs from the committed plan");
297
339
  const planHash = hashLaunchPlan(plan); const launchId = hashLaunchIdentity(plan);
298
- const remoteHash = await readLifecycleContract<Hex>(client, plan.orchestrator, launchLifecycleAbi, "hashPlan", [plan], block);
299
- const remoteIdentity = await readLifecycleContract<Hex>(client, plan.orchestrator, launchLifecycleAbi, "launchIdOf", [plan], block);
340
+ const [block, chainIdValue] = await Promise.all([
341
+ readLifecycleBlock(client),
342
+ lifecycleRpc(client, "eth_chainId"),
343
+ ]);
344
+ if (rpcQuantity(chainIdValue, "chain ID") !== plan.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain differs from the committed plan");
345
+ const [remoteHash, remoteIdentity, predictedToken, { tokenFactory, tokenFactoryCodeHash }, { limits }] = await Promise.all([
346
+ readLifecycleContract<Hex>(client, plan.orchestrator, launchLifecycleAbi, "hashPlan", [plan], block),
347
+ readLifecycleContract<Hex>(client, plan.orchestrator, launchLifecycleAbi, "launchIdOf", [plan], block),
348
+ readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "predictToken", [plan], block),
349
+ readTokenFactoryBinding(client, plan.orchestrator, block),
350
+ resolveLifecycleLimits({ client, account, orchestrator: plan.orchestrator, block, chainId: plan.chainId }, options.limits),
351
+ ]);
300
352
  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");
301
- const predictedToken = await readLifecycleContract<Address>(client, plan.orchestrator, launchLifecycleAbi, "predictToken", [plan], block);
302
- const { tokenFactory, tokenFactoryCodeHash } = await readTokenFactoryBinding(client, plan.orchestrator, block);
303
- const { limits } = await resolveLifecycleLimits({ client, account, orchestrator: plan.orchestrator, block, chainId: plan.chainId }, options.limits);
304
- const placeholder: LifecycleSimulation = { backend: "unavailable", confidence: "provisional", admitted: false, blockNumber: block.number, blockHash: block.hash, account, chainId: plan.chainId, limits, steps: [] };
353
+ 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: [] };
305
354
  const identity = { plan, planHash, launchId, predictedToken, account, mode, confirmations: options.confirmations ?? 1 };
306
355
  const progress = await readLaunchProgress({ client, planned: identity, receipts: options.receipts }, block);
307
356
  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 };
@@ -313,20 +362,22 @@ export async function planLaunch(options: PlanLaunchOptions): Promise<PlannedLau
313
362
  }
314
363
  // Receipt/nonce gates never silently drop the finalized deployment baseline on reload.
315
364
  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" };
316
- await assertLifecycleBlock(client, block);
365
+ await assertLifecycleBlock(client, block, plan.chainId);
317
366
  return { ...planned, hookDeployments, simulation: waiting, atomicAttempt: waiting };
318
367
  }
319
368
  if (progress.canonical.phase === LifecyclePhase.Active || progress.canonical.phase === LifecyclePhase.Cancelled) {
320
- await assertLifecycleBlock(client, block);
321
- return { ...planned, simulation: { ...placeholder, confidence: "stateful", admitted: true, reason: "Canonical launch is terminal; no transaction remains" } };
369
+ await assertLifecycleBlock(client, block, plan.chainId);
370
+ return { ...planned, simulation: { ...placeholder, confidence: "stateful", executionProof: "proved", protocolFit: "proved", admitted: true, reason: "Canonical launch is terminal; no transaction remains" } };
322
371
  }
323
- const prerequisites = progress.canonical.phase === LifecyclePhase.None ? await readFunding(client, plan, block) : { prerequisites: [], approvals: [], nativeValue: 0n, reasons: [] };
324
- const validation = await validatePendingInputs(client, planned, progress, block);
372
+ const [prerequisites, validation] = await Promise.all([
373
+ progress.canonical.phase === LifecyclePhase.None ? readFunding(client, plan, block) : { prerequisites: [], approvals: [], nativeValue: 0n, reasons: [] },
374
+ validatePendingInputs(client, planned, progress, block),
375
+ ]);
325
376
  const reasons = [...prerequisites.reasons, ...validation.reasons];
326
377
  planned = { ...planned, prerequisites: prerequisites.prerequisites, profiles: validation.profiles, hookDeployments: validation.hookDeployments };
327
378
  if (reasons.length > 0) {
328
379
  const rejected = { ...placeholder, confidence: "stateful" as const, reason: reasons.join("; ") };
329
- await assertLifecycleBlock(client, block);
380
+ await assertLifecycleBlock(client, block, plan.chainId);
330
381
  return { ...planned, simulation: rejected, atomicAttempt: rejected, transactions: orderedTransactions(plan, mode, progress, prerequisites.approvals, prerequisites.nativeValue, planned.preparationBatchSize) };
331
382
  }
332
383
  let atomicAttempt: LifecycleSimulation = { ...placeholder, reason: "Atomic execution cannot be evaluated from an already begun staged launch" };
@@ -351,6 +402,33 @@ export async function planLaunch(options: PlanLaunchOptions): Promise<PlannedLau
351
402
  return { ...planned, atomicAttempt, simulation, preparationBatchSize, transactions: attachEstimates(transactions, simulation) };
352
403
  }
353
404
 
405
+ async function preflightNextTransaction(options: BuildNextTransactionOptions, next: LifecycleTransaction, simulation: LifecycleSimulation): Promise<LifecycleTransaction> {
406
+ if (next.gas === undefined || next.gas <= 0n || next.gas >= 1n << 64n || next.gasPrice === undefined || next.gasPrice < 0n ||
407
+ simulation.executionProof !== "proved" || simulation.protocolFit !== "proved" || !simulation.admitted ||
408
+ next.estimate === undefined || next.estimate.gasLimit !== next.gas || next.estimate.gasPrice !== next.gasPrice ||
409
+ simulation.limits.transactionGasCeiling !== undefined && next.gas > simulation.limits.transactionGasCeiling) {
410
+ throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Next transaction has no current-state exact gas/headroom/fee-envelope proof", simulation);
411
+ }
412
+ const submissionClient = options.submissionClient;
413
+ if (submissionClient === undefined) return { ...next, dependencies: [] };
414
+ try {
415
+ if (rpcQuantity(await lifecycleRpc(submissionClient, "eth_chainId"), "submission chain ID") !== simulation.chainId) throw new Error("Active submission RPC chain differs from the reviewed transaction");
416
+ const estimated = rpcQuantity(await lifecycleRpc(submissionClient, "eth_estimateGas", [{
417
+ from: next.from, to: next.to, data: next.data, value: toHex(next.value), gas: toHex(next.gas), gasPrice: toHex(next.gasPrice),
418
+ }, "latest"]), "submission gas estimate");
419
+ if (estimated <= 0n || estimated > next.gas) throw new Error("Submission RPC gas estimate exceeds the exact reviewed envelope or is zero");
420
+ if (rpcQuantity(await lifecycleRpc(submissionClient, "eth_chainId"), "submission chain ID") !== simulation.chainId) throw new Error("Active submission RPC chain changed during preflight");
421
+ } catch (failure) {
422
+ const reason = `Exact next-transaction submission preflight failed: ${failure instanceof Error ? failure.message : String(failure)}`;
423
+ throw new LifecyclePlanningError("SUBMISSION_PREFLIGHT_FAILED", reason, { ...simulation, transportPreflight: "failed", reason });
424
+ }
425
+ const canonical = await readLifecycleBlock(options.client, toHex(simulation.blockNumber));
426
+ if (canonical.hash.toLowerCase() !== simulation.blockHash.toLowerCase()) throw new LifecyclePlanningError("STATE_REORGED", "Pinned launch proof changed during submission preflight; rebuild from canonical progress");
427
+ if (rpcQuantity(await lifecycleRpc(options.client, "eth_chainId"), "chain ID") !== simulation.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Source RPC chain changed during submission preflight");
428
+ if (next.admission === undefined) throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Next transaction omitted its exact simulation admission", simulation);
429
+ return { ...next, dependencies: [], admission: { ...next.admission, transportPreflight: "passed" } };
430
+ }
431
+
354
432
  /** Re-reads canonical state, revalidates all prerequisites, and proves the remaining sequence afresh. */
355
433
  export async function buildNextTransaction(options: BuildNextTransactionOptions): Promise<LifecycleTransaction | undefined> {
356
434
  const block = await readLifecycleBlock(options.client);
@@ -368,17 +446,18 @@ export async function buildNextTransaction(options: BuildNextTransactionOptions)
368
446
  if (progress.canonical.phase === LifecyclePhase.None) throw new LifecyclePlanningError("NOT_STARTED", "An unstarted plan has no launch escrow to cancel");
369
447
  const { planned } = options;
370
448
  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 }] };
371
- const simulation = await simulateLaunchTransactions({ client: options.client, planned, limits: options.limits ?? planned.limits, fork: options.fork }, [transaction], block);
449
+ const simulation = await simulateLaunchTransactions({ client: options.client, planned, limits: options.limits === undefined ? planned.limits : options.limits, fork: options.fork }, [transaction], block);
372
450
  if (!simulation.admitted) throw new LifecyclePlanningError("CANCEL_NOT_ADMITTED", simulation.reason ?? "Cancellation could not be proved against current canonical state", simulation);
373
- return attachEstimates([transaction], simulation)[0];
451
+ const next = attachEstimates([transaction], simulation)[0];
452
+ if (next === undefined) throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Cancellation omitted its exact simulation admission", simulation);
453
+ return preflightNextTransaction(options, next, simulation);
374
454
  }
375
- const refreshed = await planLaunch({ client: options.client, plan: options.planned.plan, account: options.planned.account, mode: options.planned.mode, limits: options.limits ?? options.planned.limits, fork: options.fork, receipts: options.receipts, confirmations: options.confirmations ?? options.planned.confirmations });
455
+ 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 });
376
456
  if (!refreshed.simulation.admitted) throw new LifecyclePlanningError("PLAN_NOT_ADMITTED", refreshed.simulation.reason ?? "Remaining exact launch sequence is not admitted", refreshed.simulation);
377
457
  assertPlannedDeploymentBindings(options.planned, refreshed);
378
458
  const next = refreshed.transactions[0];
379
459
  if (next === undefined) return undefined;
380
- if (next.gas === undefined || next.gasPrice === undefined || next.gas > refreshed.simulation.limits.executionGasCeiling) throw new LifecyclePlanningError("MISSING_GAS_PROOF", "Next transaction has no current-state exact gas/headroom/fee-envelope proof");
381
- return { ...next, dependencies: [] };
460
+ return preflightNextTransaction(options, next, refreshed.simulation);
382
461
  }
383
462
 
384
463
  /** Re-simulates only canonical unfinished work with the same submitted receipt
@@ -388,7 +467,7 @@ export async function simulateLaunchPlan(options: SimulateLaunchPlanOptions): Pr
388
467
  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");
389
468
  const current = await planLaunch({
390
469
  client, plan: planned.plan, account: planned.account, mode: planned.mode,
391
- limits: options.limits ?? planned.limits, fork: options.fork,
470
+ limits: options.limits === undefined ? planned.limits : options.limits, fork: options.fork,
392
471
  receipts: options.receipts, confirmations: options.confirmations ?? planned.confirmations,
393
472
  });
394
473
  assertPlannedDeploymentBindings(planned, current);