@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.
- package/README.md +8 -0
- package/dist/lifecycle/planner.d.ts.map +1 -1
- package/dist/lifecycle/planner.js +237 -141
- package/dist/lifecycle/planner.js.map +1 -1
- package/dist/lifecycle/progress.d.ts.map +1 -1
- package/dist/lifecycle/progress.js +204 -143
- package/dist/lifecycle/progress.js.map +1 -1
- package/dist/lifecycle/rpc.d.ts +42 -2
- package/dist/lifecycle/rpc.d.ts.map +1 -1
- package/dist/lifecycle/rpc.js +167 -28
- package/dist/lifecycle/rpc.js.map +1 -1
- package/dist/lifecycle/simulation.d.ts.map +1 -1
- package/dist/lifecycle/simulation.js +106 -33
- package/dist/lifecycle/simulation.js.map +1 -1
- package/dist/lifecycle/types.d.ts +29 -4
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lifecycle/types.js.map +1 -1
- package/docs/launch-lifecycle.md +198 -15
- package/examples/.env.example +7 -10
- package/examples/README.md +24 -18
- package/examples/launch-example-support.mjs +8 -12
- package/examples/launch-example.mjs +15 -18
- package/package.json +1 -1
- package/src/lifecycle/planner.ts +195 -116
- package/src/lifecycle/progress.ts +187 -126
- package/src/lifecycle/rpc.ts +156 -27
- package/src/lifecycle/simulation.ts +95 -31
- package/src/lifecycle/types.ts +23 -5
package/src/lifecycle/rpc.ts
CHANGED
|
@@ -1,10 +1,83 @@
|
|
|
1
|
-
import { decodeFunctionResult, encodeFunctionData, isHex, toHex, type Abi, type Address, type Hex } from "viem";
|
|
2
|
-
import { LifecyclePlanningError, type LifecycleBlock, type LifecycleLimitContext, type LifecycleLimitSource, type LifecycleLimits, type LifecycleRpcClient, type ResolvedLifecycleLimits } from "./types.js";
|
|
1
|
+
import { decodeFunctionResult, encodeFunctionData, isAddress, isHex, parseAbi, toHex, type Abi, type Address, type Hex } from "viem";
|
|
2
|
+
import { LifecyclePlanningError, type LifecycleBlock, type LifecycleLimitContext, type LifecycleLimitSource, type LifecycleLimits, type LifecycleRpcClient, type LifecycleTransaction, type ResolvedLifecycleLimits } from "./types.js";
|
|
3
3
|
import { lifecycleRegistryAbi } from "./abi.js";
|
|
4
4
|
import type { ProfileTopologyV1 } from "./schema.js";
|
|
5
5
|
|
|
6
|
+
const lifecycleReadConcurrency = 8;
|
|
7
|
+
|
|
8
|
+
/** Owns only one invocation's immutable RPC observations, never live checks or writes. */
|
|
9
|
+
class InvocationLifecycleClient implements LifecycleRpcClient {
|
|
10
|
+
private readonly reads = new Map<string, Promise<unknown>>();
|
|
11
|
+
private readonly active = new Set<Promise<void>>();
|
|
12
|
+
private closed = false;
|
|
13
|
+
private readonly concurrency: number;
|
|
14
|
+
constructor(readonly source: LifecycleRpcClient) {
|
|
15
|
+
this.concurrency = source.supportsReadBatching === true ? lifecycleReadConcurrency : 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async request(args: { method: string; params?: readonly unknown[] }): Promise<unknown> {
|
|
19
|
+
this.assertOpen();
|
|
20
|
+
const result = await this.source.request(args);
|
|
21
|
+
this.assertOpen();
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private assertOpen(): void {
|
|
26
|
+
if (this.closed) throw new LifecyclePlanningError("READ_SCOPE_CLOSED", "Lifecycle read invocation has already settled");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async run(method: string, params: readonly unknown[]): Promise<unknown> {
|
|
30
|
+
this.assertOpen();
|
|
31
|
+
while (this.active.size >= this.concurrency) await Promise.race(this.active);
|
|
32
|
+
this.assertOpen();
|
|
33
|
+
const pending = this.request({ method, params });
|
|
34
|
+
const settled = pending.then(
|
|
35
|
+
() => { this.active.delete(settled); },
|
|
36
|
+
() => { this.active.delete(settled); },
|
|
37
|
+
);
|
|
38
|
+
this.active.add(settled);
|
|
39
|
+
return pending;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
read(method: string, params: readonly unknown[], block: LifecycleBlock): Promise<unknown> {
|
|
43
|
+
this.assertOpen();
|
|
44
|
+
// Source identity is this scope's original client. The complete wire call includes
|
|
45
|
+
// caller, value, gas and any other call fields; no selector-only/ABI-name aliasing.
|
|
46
|
+
const key = JSON.stringify([block.number.toString(), block.hash.toLowerCase(), method, params]);
|
|
47
|
+
const existing = this.reads.get(key);
|
|
48
|
+
if (existing !== undefined) return existing;
|
|
49
|
+
const pending = this.run(method, params);
|
|
50
|
+
this.reads.set(key, pending);
|
|
51
|
+
void pending.catch(() => { if (this.reads.get(key) === pending) this.reads.delete(key); });
|
|
52
|
+
return pending;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
close(): void {
|
|
56
|
+
this.closed = true;
|
|
57
|
+
this.reads.clear();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Internal callers join the same invocation; no process-wide/source-wide read cache. */
|
|
62
|
+
export async function withLifecycleReadClient<T>(source: LifecycleRpcClient, work: (client: LifecycleRpcClient) => Promise<T>): Promise<T> {
|
|
63
|
+
if (source instanceof InvocationLifecycleClient) return work(source);
|
|
64
|
+
const client = new InvocationLifecycleClient(source);
|
|
65
|
+
try { return await work(client); }
|
|
66
|
+
finally { client.close(); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function lifecycleSourceClient(client: LifecycleRpcClient): LifecycleRpcClient {
|
|
70
|
+
return client instanceof InvocationLifecycleClient ? client.source : client;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Only explicitly pinned fixed-state reads may participate in invocation reuse. */
|
|
74
|
+
export async function lifecyclePinnedRpc(client: LifecycleRpcClient, method: "eth_call" | "eth_getCode" | "eth_getBalance", params: readonly unknown[], block: LifecycleBlock): Promise<unknown> {
|
|
75
|
+
if (params.length !== 2 || params[1] !== toHex(block.number)) throw new LifecyclePlanningError("INVALID_READ_CONTEXT", "Reusable lifecycle reads require the exact pinned block and no state overrides");
|
|
76
|
+
return client instanceof InvocationLifecycleClient ? client.read(method, params, block) : lifecycleRpc(client, method, params);
|
|
77
|
+
}
|
|
78
|
+
|
|
6
79
|
export async function lifecycleRpc(client: LifecycleRpcClient, method: string, params: readonly unknown[] = []): Promise<unknown> {
|
|
7
|
-
return client.request({ method, params });
|
|
80
|
+
return client instanceof InvocationLifecycleClient ? client.run(method, params) : client.request({ method, params });
|
|
8
81
|
}
|
|
9
82
|
export function rpcObject(value: unknown, label: string): Record<string, unknown> {
|
|
10
83
|
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", `${label} must be an object`);
|
|
@@ -31,13 +104,18 @@ export async function readLifecycleBlock(client: LifecycleRpcClient, tag: Hex |
|
|
|
31
104
|
baseFeePerGas: value.baseFeePerGas === undefined || value.baseFeePerGas === null ? undefined : rpcQuantity(value.baseFeePerGas, "base fee"),
|
|
32
105
|
};
|
|
33
106
|
}
|
|
34
|
-
export async function assertLifecycleBlock(client: LifecycleRpcClient, block: LifecycleBlock): Promise<void> {
|
|
107
|
+
export async function assertLifecycleBlock(client: LifecycleRpcClient, block: LifecycleBlock, expectedChainId?: bigint): Promise<void> {
|
|
108
|
+
if (expectedChainId !== undefined && rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID") !== expectedChainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "Source RPC chain changed during the pinned operation");
|
|
35
109
|
const current = await readLifecycleBlock(client, toHex(block.number));
|
|
36
110
|
if (current.hash.toLowerCase() !== block.hash.toLowerCase()) throw new LifecyclePlanningError("STATE_REORGED", "Pinned launch state changed during the operation; read canonical progress again");
|
|
37
111
|
}
|
|
38
112
|
export async function readLifecycleContract<T>(client: LifecycleRpcClient, to: Address, abi: Abi, functionName: string, args: readonly unknown[], block: LifecycleBlock, from?: Address): Promise<T> {
|
|
39
113
|
const data = encodeFunctionData({ abi, functionName, args });
|
|
40
|
-
const
|
|
114
|
+
const request = [{ to, from, data }, toHex(block.number)];
|
|
115
|
+
const getter = abi.some((item) => item.type === "function" && item.name === functionName && (item.stateMutability === "view" || item.stateMutability === "pure"));
|
|
116
|
+
const result = rpcHex(await (getter && functionName !== "readLaunchProgress"
|
|
117
|
+
? lifecyclePinnedRpc(client, "eth_call", request, block)
|
|
118
|
+
: lifecycleRpc(client, "eth_call", request)), functionName);
|
|
41
119
|
// ABI decoding validates scalar widths and tuple layout; T names that exact frozen ABI result.
|
|
42
120
|
const decoded = decodeFunctionResult({ abi, functionName, data: result }) as T;
|
|
43
121
|
return decoded;
|
|
@@ -49,40 +127,91 @@ export async function readLifecycleProfileTopology(client: LifecycleRpcClient, r
|
|
|
49
127
|
if (topology.hookTopology !== 0 && topology.hookTopology !== 1 && topology.hookTopology !== 2) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Registry returned an unsupported hook topology");
|
|
50
128
|
return { ...topology, hookTopology: topology.hookTopology };
|
|
51
129
|
}
|
|
130
|
+
export const nitroArbSys = "0x0000000000000000000000000000000000000064";
|
|
131
|
+
export const nitroGasInfo = "0x000000000000000000000000000000000000006c";
|
|
132
|
+
export const nitroNodeInterface = "0x00000000000000000000000000000000000000c8";
|
|
133
|
+
export const nitroArbSysAbi = parseAbi(["function arbOSVersion() view returns (uint256)"]);
|
|
134
|
+
export const nitroGasInfoAbi = parseAbi(["function getMaxTxGasLimit() view returns (uint256)", "function getMaxBlockGasLimit() view returns (uint64)"]);
|
|
135
|
+
const nitroNodeInterfaceAbi = parseAbi(["function gasEstimateL1Component(address to, bool contractCreation, bytes data) payable returns (uint64 gasEstimateForL1, uint256 baseFee, uint256 l1BaseFeeEstimate)"]);
|
|
136
|
+
const maxUint64 = (1n << 64n) - 1n;
|
|
137
|
+
|
|
138
|
+
/** A poster quote is an envelope budget, never an execution/compute proof. */
|
|
139
|
+
export async function readNitroPosterGas(transaction: LifecycleTransaction, context: LifecycleLimitContext): Promise<{ posterGas: bigint; baseFee: bigint; l1BaseFeeEstimate: bigint }> {
|
|
140
|
+
const data = encodeFunctionData({ abi: nitroNodeInterfaceAbi, functionName: "gasEstimateL1Component", args: [transaction.to, false, transaction.data] });
|
|
141
|
+
const result = rpcHex(await lifecyclePinnedRpc(context.client, "eth_call", [{ from: transaction.from, to: nitroNodeInterface, data, value: toHex(transaction.value) }, toHex(context.block.number)], context.block), "Nitro poster estimate");
|
|
142
|
+
if (result.length !== 194) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Nitro poster estimate must contain exactly three ABI words");
|
|
143
|
+
const [posterGas, baseFee, l1BaseFeeEstimate] = decodeFunctionResult({ abi: nitroNodeInterfaceAbi, functionName: "gasEstimateL1Component", data: result });
|
|
144
|
+
if (posterGas < 0n || posterGas > maxUint64 || baseFee <= 0n || l1BaseFeeEstimate < 0n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Nitro poster estimate has invalid gas or fee values");
|
|
145
|
+
return { posterGas, baseFee, l1BaseFeeEstimate };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readNitroScalar(context: LifecycleLimitContext, to: Address, abi: Abi, functionName: string): Promise<bigint> {
|
|
149
|
+
const data = encodeFunctionData({ abi, functionName });
|
|
150
|
+
const result = rpcHex(await lifecyclePinnedRpc(context.client, "eth_call", [{ to, data }, toHex(context.block.number)], context.block), functionName);
|
|
151
|
+
if (result.length !== 66) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", `${functionName} must return exactly one ABI word`);
|
|
152
|
+
return decodeFunctionResult({ abi, functionName, data: result }) as bigint;
|
|
153
|
+
}
|
|
154
|
+
|
|
52
155
|
export async function resolveLifecycleLimits(context: LifecycleLimitContext, source?: LifecycleLimitSource): Promise<{ limits: ResolvedLifecycleLimits; configuration: LifecycleLimits }> {
|
|
53
|
-
let configuration: LifecycleLimits
|
|
54
|
-
|
|
55
|
-
try { configuration = typeof source === "function" ? await source(
|
|
56
|
-
catch (failure) {
|
|
57
|
-
|
|
156
|
+
let configuration: LifecycleLimits;
|
|
157
|
+
const policyContext = typeof source === "function" && context.client instanceof InvocationLifecycleClient ? { ...context, client: context.client.source } : context;
|
|
158
|
+
try { configuration = source === undefined ? {} : typeof source === "function" ? await source(policyContext) : source; }
|
|
159
|
+
catch (failure) { throw new LifecyclePlanningError("LIMIT_SOURCE_FAILED", `Supplied execution policy could not be resolved: ${failure instanceof Error ? failure.message : String(failure)}`); }
|
|
160
|
+
if (configuration === null || typeof configuration !== "object" || Array.isArray(configuration)) throw new LifecyclePlanningError("INVALID_LIMITS", "Supplied execution policy must be an object");
|
|
161
|
+
const headroomBps = configuration.headroomBps === undefined ? 1500 : configuration.headroomBps;
|
|
58
162
|
if (!Number.isSafeInteger(headroomBps) || headroomBps < 0 || headroomBps > 10000) throw new LifecyclePlanningError("INVALID_LIMITS", "Headroom must be between zero and 10000 basis points");
|
|
59
|
-
const
|
|
60
|
-
|
|
163
|
+
for (const limit of [context.block.gasLimit, configuration.chainGasLimit, configuration.rpcGasLimit, configuration.accountGasLimit, configuration.maxSimulationGas]) {
|
|
164
|
+
if (limit !== undefined && (typeof limit !== "bigint" || limit <= 0n || limit > maxUint64)) throw new LifecyclePlanningError("INVALID_LIMITS", "Gas caps must be positive exact uint64 bigint values");
|
|
165
|
+
}
|
|
61
166
|
if (configuration.maxCalldataBytes !== undefined && (!Number.isSafeInteger(configuration.maxCalldataBytes) || configuration.maxCalldataBytes <= 0)) throw new LifecyclePlanningError("INVALID_LIMITS", "Calldata cap must be a positive byte count");
|
|
62
|
-
if (configuration.
|
|
167
|
+
if (configuration.estimateDataFee !== undefined && typeof configuration.estimateDataFee !== "function") throw new LifecyclePlanningError("INVALID_LIMITS", "External data-fee estimator must be a function");
|
|
168
|
+
if (configuration.chainId !== undefined && (typeof configuration.chainId !== "bigint" || configuration.chainId !== context.chainId) ||
|
|
169
|
+
configuration.observedBlockNumber !== undefined && (typeof configuration.observedBlockNumber !== "bigint" || configuration.observedBlockNumber !== context.block.number) ||
|
|
170
|
+
configuration.observedBlockHash !== undefined && (typeof configuration.observedBlockHash !== "string" || !/^0x[\da-fA-F]{64}$/.test(configuration.observedBlockHash) || configuration.observedBlockHash.toLowerCase() !== context.block.hash.toLowerCase()) ||
|
|
171
|
+
configuration.account !== undefined && (typeof configuration.account !== "string" || !isAddress(configuration.account) || configuration.account.toLowerCase() !== context.account.toLowerCase()) ||
|
|
172
|
+
configuration.orchestrator !== undefined && (typeof configuration.orchestrator !== "string" || !isAddress(configuration.orchestrator) || configuration.orchestrator.toLowerCase() !== context.orchestrator.toLowerCase())) {
|
|
173
|
+
throw new LifecyclePlanningError("LIMIT_CONTEXT_MISMATCH", "Supplied execution policy provenance differs from the canonical chain/account/core/block context");
|
|
174
|
+
}
|
|
175
|
+
const protocol = context.chainId === 4663n ? "nitro" : "evm";
|
|
176
|
+
let arbOSVersion: bigint | undefined; let maxTxComputeGas: bigint | undefined; let maxBlockComputeGas: bigint | undefined;
|
|
177
|
+
if (protocol === "nitro") {
|
|
178
|
+
try {
|
|
179
|
+
const [rawVersion, txComputeGas, blockComputeGas] = await Promise.all([
|
|
180
|
+
readNitroScalar(context, nitroArbSys, nitroArbSysAbi, "arbOSVersion"),
|
|
181
|
+
readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxTxGasLimit"),
|
|
182
|
+
readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxBlockGasLimit"),
|
|
183
|
+
]);
|
|
184
|
+
// ArbSys alone adds 55 to the logical Nitro ArbOS version.
|
|
185
|
+
if (typeof rawVersion !== "bigint" || rawVersion < 105n) throw new Error("ArbOS version 50 or newer is required (ArbSys raw version >= 105)");
|
|
186
|
+
arbOSVersion = rawVersion - 55n;
|
|
187
|
+
maxTxComputeGas = txComputeGas;
|
|
188
|
+
maxBlockComputeGas = blockComputeGas;
|
|
189
|
+
if ([maxTxComputeGas, maxBlockComputeGas].some((limit) => typeof limit !== "bigint" || limit <= 0n || limit > maxUint64)) throw new Error("Nitro compute getters must be positive uint64 values");
|
|
190
|
+
} catch (failure) { throw new LifecyclePlanningError("NITRO_LIMITS_UNAVAILABLE", `Pinned Nitro compute limits could not be established: ${failure instanceof Error ? failure.message : String(failure)}; header gasLimit is not a fallback`); }
|
|
191
|
+
if (configuration.estimateDataFee !== undefined) throw new LifecyclePlanningError("INVALID_LIMITS", "Nitro poster fees are already included in gas; an external data-fee estimator would double-charge them");
|
|
192
|
+
}
|
|
193
|
+
const envelopeCaps = [configuration.chainGasLimit, configuration.rpcGasLimit, configuration.accountGasLimit].filter((limit): limit is bigint => limit !== undefined);
|
|
194
|
+
if (protocol === "evm") envelopeCaps.push(context.block.gasLimit);
|
|
195
|
+
const transactionGasCeiling = envelopeCaps.length === 0 ? undefined : envelopeCaps.reduce((minimum, limit) => limit < minimum ? limit : minimum);
|
|
196
|
+
const executionGasCeiling = maxTxComputeGas !== undefined && maxBlockComputeGas !== undefined
|
|
197
|
+
? (maxTxComputeGas < maxBlockComputeGas ? maxTxComputeGas : maxBlockComputeGas)
|
|
198
|
+
: transactionGasCeiling ?? context.block.gasLimit;
|
|
63
199
|
const unknownExecutionConstraints = [
|
|
64
|
-
...(configuration.chainGasLimit === undefined ? ["
|
|
65
|
-
...(configuration.rpcGasLimit === undefined ? ["RPC transaction
|
|
66
|
-
...(configuration.accountGasLimit === undefined ? ["Current account/wallet transaction
|
|
200
|
+
...(protocol === "evm" && configuration.chainGasLimit === undefined ? ["Independent chain per-transaction execution cap is unknown; proof is bounded by the pinned header and validated backend"] : []),
|
|
201
|
+
...(configuration.rpcGasLimit === undefined ? ["RPC transaction gas-envelope cap is unknown"] : []),
|
|
202
|
+
...(configuration.accountGasLimit === undefined ? ["Current account/wallet transaction gas-envelope cap is unknown"] : []),
|
|
67
203
|
...(configuration.maxCalldataBytes === undefined ? ["Applicable chain/account/RPC calldata byte cap is unknown"] : []),
|
|
68
|
-
...(configuration.
|
|
69
|
-
...(configuration.account?.toLowerCase() !== context.account.toLowerCase() ? ["Current limit account provenance is missing or mismatched"] : []),
|
|
70
|
-
...(configuration.orchestrator?.toLowerCase() !== context.orchestrator.toLowerCase() ? ["Current limit orchestrator provenance is missing or mismatched"] : []),
|
|
71
|
-
...(configuration.observedBlockNumber !== context.block.number || configuration.observedBlockHash?.toLowerCase() !== context.block.hash.toLowerCase() ? ["Current limit block provenance is missing, stale, or mismatched"] : []),
|
|
72
|
-
...(sourceError === undefined ? [] : [`Current limit source failed: ${sourceError}`]),
|
|
204
|
+
...(configuration.maxSimulationGas === undefined ? ["RPC aggregate simulation gas cap is unknown"] : []),
|
|
73
205
|
];
|
|
74
206
|
return {
|
|
75
207
|
configuration,
|
|
76
208
|
limits: {
|
|
77
|
-
executionGasCeiling
|
|
209
|
+
protocol, executionGasCeiling, transactionGasCeiling, arbOSVersion, maxTxComputeGas, maxBlockComputeGas, headroomBps,
|
|
78
210
|
maxCalldataBytes: configuration.maxCalldataBytes, maxSimulationGas: configuration.maxSimulationGas,
|
|
79
211
|
blockGasLimit: context.block.gasLimit, chainGasLimit: configuration.chainGasLimit,
|
|
80
212
|
rpcGasLimit: configuration.rpcGasLimit, accountGasLimit: configuration.accountGasLimit,
|
|
81
|
-
|
|
82
|
-
unknownConstraints: [
|
|
83
|
-
...unknownExecutionConstraints,
|
|
84
|
-
...(configuration.estimateDataFee === undefined ? ["Chain-specific data fee is unavailable"] : []),
|
|
85
|
-
],
|
|
213
|
+
unknownExecutionConstraints,
|
|
214
|
+
unknownConstraints: [...unknownExecutionConstraints, ...(protocol === "evm" && configuration.estimateDataFee === undefined ? ["External chain-specific data fee is unavailable"] : [])],
|
|
86
215
|
},
|
|
87
216
|
};
|
|
88
217
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { decodeEventLog, decodeFunctionResult, encodeFunctionData, encodeFunctionResult, toHex, type Hex } from "viem";
|
|
2
2
|
import { launchLifecycleAbi, lifecycleErc20Abi } from "./abi.js";
|
|
3
3
|
import { LifecyclePhase, type LaunchProgressV1, type LaunchReceiptV1 } from "./schema.js";
|
|
4
|
-
import { assertLifecycleBlock, lifecycleRpc, readLifecycleBlock, resolveLifecycleLimits, rpcHex, rpcObject, rpcQuantity } from "./rpc.js";
|
|
4
|
+
import { assertLifecycleBlock, lifecyclePinnedRpc, lifecycleRpc, lifecycleSourceClient, nitroArbSys, nitroArbSysAbi, nitroGasInfo, nitroGasInfoAbi, readLifecycleBlock, readNitroPosterGas, resolveLifecycleLimits, rpcHex, rpcObject, rpcQuantity } from "./rpc.js";
|
|
5
5
|
import { LifecyclePlanningError, type ControlledLifecycleFork, type LifecycleBlock, type LifecyclePostcondition, type LifecycleRpcClient, type LifecycleSimulation, type LifecycleSimulationStep, type LifecycleTransaction, type PlannedLaunch, type ResolvedLifecycleLimits, type SimulateLaunchPlanOptions } from "./types.js";
|
|
6
6
|
|
|
7
7
|
function conditionRequest(condition: LifecyclePostcondition, planned: PlannedLaunch): { to: Hex; data: Hex } {
|
|
@@ -24,6 +24,40 @@ function checkCondition(condition: LifecyclePostcondition, data: Hex, planned: P
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Native Nitro eth_simulateV1 executes commit-mode ArbOS charging, including its
|
|
28
|
+
* compute hold after the actual poster charge. A generic EVM/fork is not this proof. */
|
|
29
|
+
async function proveNitroBackend(client: LifecycleRpcClient, planned: PlannedLaunch, block: LifecycleBlock, limits: ResolvedLifecycleLimits, gasPrice: bigint): Promise<void> {
|
|
30
|
+
if (limits.arbOSVersion === undefined || limits.maxTxComputeGas === undefined || limits.maxBlockComputeGas === undefined) throw new LifecyclePlanningError("NITRO_METERING_UNAVAILABLE", "Pinned Nitro metering context is incomplete");
|
|
31
|
+
const probes = [
|
|
32
|
+
{ to: nitroArbSys, data: encodeFunctionData({ abi: nitroArbSysAbi, functionName: "arbOSVersion" }), expected: limits.arbOSVersion + 55n },
|
|
33
|
+
{ to: nitroGasInfo, data: encodeFunctionData({ abi: nitroGasInfoAbi, functionName: "getMaxTxGasLimit" }), expected: limits.maxTxComputeGas },
|
|
34
|
+
{ to: nitroGasInfo, data: encodeFunctionData({ abi: nitroGasInfoAbi, functionName: "getMaxBlockGasLimit" }), expected: limits.maxBlockComputeGas },
|
|
35
|
+
];
|
|
36
|
+
const gas = limits.executionGasCeiling < 100_000n ? limits.executionGasCeiling : 100_000n;
|
|
37
|
+
if (limits.maxSimulationGas !== undefined && gas * BigInt(probes.length) > limits.maxSimulationGas) throw new LifecyclePlanningError("SIMULATION_RPC_CAP", "Nitro metering probe exceeds the supplied RPC aggregate simulation cap");
|
|
38
|
+
// This isolated read-only request does not add synthetic verification transactions to the launch sequence.
|
|
39
|
+
const result = await lifecycleRpc(client, "eth_simulateV1", [{
|
|
40
|
+
validation: true, traceTransfers: false, returnFullTransactions: false,
|
|
41
|
+
blockStateCalls: [{
|
|
42
|
+
// Capability reads are isolated from payer affordability. Real launch replay never overrides balances.
|
|
43
|
+
stateOverrides: { [planned.account]: { balance: toHex((1n << 256n) - 1n) } },
|
|
44
|
+
blockOverrides: { number: toHex(block.number + 1n), time: toHex(block.timestamp + 1n), gasLimit: toHex(block.gasLimit) },
|
|
45
|
+
calls: probes.map(({ to, data }) => ({ from: planned.account, to, data, value: "0x0", gas: toHex(gas), gasPrice: toHex(gasPrice) })),
|
|
46
|
+
}],
|
|
47
|
+
}, toHex(block.number)]);
|
|
48
|
+
if (!Array.isArray(result) || result.length !== 1) throw new LifecyclePlanningError("NITRO_METERING_UNAVAILABLE", "Native simulation omitted the pinned ArbOS metering probe");
|
|
49
|
+
const simulated = rpcObject(result[0], "Nitro metering probe block");
|
|
50
|
+
if (!Array.isArray(simulated.calls) || simulated.calls.length !== probes.length) throw new LifecyclePlanningError("NITRO_METERING_UNAVAILABLE", "Native simulation omitted ArbOS version or compute getters");
|
|
51
|
+
for (let index = 0; index < probes.length; index += 1) {
|
|
52
|
+
const call = rpcObject(simulated.calls[index], "Nitro metering probe");
|
|
53
|
+
const data = rpcHex(call.returnData, "simulated Nitro metering result");
|
|
54
|
+
if (rpcQuantity(call.status, "Nitro metering probe status") !== 1n || rpcQuantity(call.gasUsed, "Nitro metering probe gas") > gas || data.length !== 66 || BigInt(data) !== probes[index]?.expected) {
|
|
55
|
+
throw new LifecyclePlanningError("NITRO_METERING_UNAVAILABLE", "Simulated ArbOS version/compute getters differ from the exact pinned chain context");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
59
|
+
}
|
|
60
|
+
|
|
27
61
|
async function simulateRpcPass(client: LifecycleRpcClient, planned: PlannedLaunch, block: LifecycleBlock, transactions: readonly LifecycleTransaction[], gasLimits: readonly bigint[], limits: ResolvedLifecycleLimits, gasPrice: bigint, validation: boolean): Promise<LifecycleSimulationStep[]> {
|
|
28
62
|
const totalRequestedGas = gasLimits.reduce((total, gas) => total + gas, 0n);
|
|
29
63
|
if (limits.maxSimulationGas !== undefined && totalRequestedGas > limits.maxSimulationGas) throw new LifecyclePlanningError("SIMULATION_RPC_CAP", "Sequential request exceeds the current RPC aggregate simulation gas cap; use an isolated controlled fork");
|
|
@@ -46,6 +80,8 @@ async function simulateRpcPass(client: LifecycleRpcClient, planned: PlannedLaunc
|
|
|
46
80
|
if (!Array.isArray(simulatedBlock.calls) || simulatedBlock.calls.length !== 1) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Sequential simulation omitted transaction results");
|
|
47
81
|
const call = rpcObject(simulatedBlock.calls[0], "simulated transaction");
|
|
48
82
|
const gasUsed = rpcQuantity(call.gasUsed, "simulated gas used");
|
|
83
|
+
const gasRequired = call.maxUsedGas === undefined ? gasUsed : rpcQuantity(call.maxUsedGas, "simulated gross gas");
|
|
84
|
+
if (gasRequired < gasUsed || gasRequired > (gasLimits[index] ?? 0n)) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Simulated gas use is outside the exact requested envelope");
|
|
49
85
|
const returnData = rpcHex(call.returnData, "simulated return data");
|
|
50
86
|
const success = rpcQuantity(call.status, "simulated status") === 1n;
|
|
51
87
|
let error: string | undefined;
|
|
@@ -78,7 +114,7 @@ async function simulateRpcPass(client: LifecycleRpcClient, planned: PlannedLaunc
|
|
|
78
114
|
else if ((transaction.kind === "approve" || transaction.kind === "approve-reset") && returnData !== "0x" && decodeFunctionResult({ abi: lifecycleErc20Abi, functionName: "approve", data: returnData }) !== true) throw new LifecyclePlanningError("POSTCONDITION_FAILED", "Funding approval returned failure");
|
|
79
115
|
} catch (failure) { error = failure instanceof Error ? failure.message : String(failure); }
|
|
80
116
|
}
|
|
81
|
-
steps.push({ transactionId: transaction.id, success: success && error === undefined, gasUsed, gasLimit: gasLimits[index], returnData, error });
|
|
117
|
+
steps.push({ transactionId: transaction.id, success: success && error === undefined, gasUsed, gasRequired, gasLimit: gasLimits[index], returnData, error });
|
|
82
118
|
if (!success || error !== undefined) break;
|
|
83
119
|
}
|
|
84
120
|
return steps;
|
|
@@ -198,39 +234,63 @@ async function simulateForkPass(fork: ControlledLifecycleFork, planned: PlannedL
|
|
|
198
234
|
|
|
199
235
|
export async function simulateLaunchTransactions(options: SimulateLaunchPlanOptions, transactions: readonly LifecycleTransaction[], pinnedBlock?: LifecycleBlock): Promise<LifecycleSimulation> {
|
|
200
236
|
const { client, planned } = options;
|
|
201
|
-
|
|
237
|
+
const sourceClient = lifecycleSourceClient(client);
|
|
238
|
+
if (options.fork !== undefined && lifecycleSourceClient(options.fork.client) === sourceClient) throw new LifecyclePlanningError("UNSAFE_FORK", "Source RPC must never receive controlled simulation transactions/snapshots/resets");
|
|
202
239
|
const block = pinnedBlock ?? await readLifecycleBlock(client);
|
|
203
240
|
const context = { client, account: planned.account, orchestrator: planned.plan.orchestrator, block, chainId: planned.chainId };
|
|
204
|
-
const { limits, configuration } = await resolveLifecycleLimits(context, options.limits
|
|
205
|
-
const base = { confidence: "stateful" as const, blockNumber: block.number, blockHash: block.hash, account: planned.account, chainId: planned.chainId, limits };
|
|
241
|
+
const { limits, configuration } = await resolveLifecycleLimits(context, options.limits === undefined ? planned.limits : options.limits);
|
|
242
|
+
const base = { confidence: "stateful" as const, executionProof: "failed" as const, protocolFit: "unknown" as const, transportPreflight: "not-requested" as const, blockNumber: block.number, blockHash: block.hash, account: planned.account, chainId: planned.chainId, limits };
|
|
206
243
|
if (rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID") !== planned.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain changed before sequential simulation");
|
|
207
|
-
const accountCode = rpcHex(await
|
|
208
|
-
if (accountCode !== "0x") return { ...base, confidence: "provisional", backend: "unavailable", admitted: false, steps: [], reason: "Direct EOA transaction simulation cannot prove smart-account signature/execution behavior" };
|
|
244
|
+
const accountCode = rpcHex(await lifecyclePinnedRpc(client, "eth_getCode", [planned.account, toHex(block.number)], block), "account code");
|
|
245
|
+
if (accountCode !== "0x") return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: "Direct EOA transaction simulation cannot prove smart-account signature/execution behavior" };
|
|
209
246
|
for (const transaction of transactions) {
|
|
210
|
-
if (
|
|
247
|
+
if (transaction.from.toLowerCase() !== planned.account.toLowerCase() || BigInt(transaction.chainId) !== planned.chainId) throw new LifecyclePlanningError("ACCOUNT_MISMATCH", "Simulation transaction differs from the committed chain/account");
|
|
248
|
+
if (limits.maxCalldataBytes !== undefined && (transaction.data.length - 2) / 2 > limits.maxCalldataBytes) return { ...base, executionProof: "unavailable", protocolFit: "failed", backend: "unavailable", admitted: false, steps: [], failedTransactionId: transaction.id, reason: "Exact calldata exceeds the supplied chain/account/RPC byte cap" };
|
|
211
249
|
}
|
|
212
|
-
if (transactions.length === 0) { await assertLifecycleBlock(client, block); return { ...base, backend: "unavailable", admitted: true, steps: [] }; }
|
|
250
|
+
if (transactions.length === 0) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, executionProof: "proved", protocolFit: "proved", backend: "unavailable", admitted: true, steps: [] }; }
|
|
213
251
|
const gasPrice = rpcQuantity(await lifecycleRpc(client, "eth_gasPrice"), "gas price");
|
|
214
|
-
|
|
252
|
+
if (gasPrice >= 1n << 256n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Gas price must be a uint256 quantity");
|
|
253
|
+
const buffer = (gas: bigint) => (gas * BigInt(10000 + limits.headroomBps) + 9999n) / 10000n;
|
|
254
|
+
const posterGas: bigint[] = [];
|
|
255
|
+
if (limits.protocol === "nitro") {
|
|
256
|
+
try {
|
|
257
|
+
await proveNitroBackend(client, planned, block, limits, gasPrice);
|
|
258
|
+
posterGas.push(...await Promise.all(transactions.map(async (transaction) => buffer((await readNitroPosterGas(transaction, context)).posterGas))));
|
|
259
|
+
} catch (failure) {
|
|
260
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
261
|
+
return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: `Native Nitro compute/poster proof unavailable: ${failure instanceof Error ? failure.message : String(failure)}. Generic Anvil/Hardhat forks cannot prove ArbOS metering.` };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const computeCeiling = limits.transactionGasCeiling !== undefined && limits.transactionGasCeiling < limits.executionGasCeiling ? limits.transactionGasCeiling : limits.executionGasCeiling;
|
|
265
|
+
const capGas = transactions.map(() => computeCeiling);
|
|
266
|
+
// The poster quote allocates gas only. The native exact replay enforces actual
|
|
267
|
+
// compute hold; subtracting this approximate budget from gasUsed would not.
|
|
268
|
+
const discoveryGas = transactions.map((_, index) => {
|
|
269
|
+
const total = limits.executionGasCeiling + (posterGas[index] ?? 0n);
|
|
270
|
+
const capped = limits.transactionGasCeiling !== undefined && limits.transactionGasCeiling < total ? limits.transactionGasCeiling : total;
|
|
271
|
+
if (capped <= 0n || capped >= 1n << 64n) throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Compute/poster envelope exceeds uint64 gas");
|
|
272
|
+
return capped;
|
|
273
|
+
});
|
|
215
274
|
let backend: "eth_simulateV1" | "controlled-fork" = "eth_simulateV1";
|
|
216
275
|
let measured: LifecycleSimulationStep[];
|
|
217
276
|
// Permissive cap measurement is not proof; only the exact-gas validated replay admits a plan.
|
|
218
277
|
try { measured = await simulateRpcPass(client, planned, block, transactions, capGas, limits, gasPrice, false); }
|
|
219
278
|
catch (failure) {
|
|
220
|
-
if (options.fork === undefined) {
|
|
221
|
-
await assertLifecycleBlock(client, block);
|
|
222
|
-
return { ...base, confidence: "provisional", backend: "unavailable", admitted: false, steps: [], reason: `Stateful sequential RPC simulation unavailable: ${failure instanceof Error ? failure.message : String(failure)}. Dependent eth_call estimates are not proof
|
|
279
|
+
if (options.fork === undefined || limits.protocol === "nitro") {
|
|
280
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
281
|
+
return { ...base, confidence: "provisional", executionProof: "unavailable", backend: "unavailable", admitted: false, steps: [], reason: `Stateful sequential RPC simulation unavailable: ${failure instanceof Error ? failure.message : String(failure)}. ${limits.protocol === "nitro" ? "Generic Anvil/Hardhat forks cannot prove ArbOS compute/poster metering." : "Dependent eth_call estimates are not proof."}` };
|
|
223
282
|
}
|
|
224
283
|
backend = "controlled-fork";
|
|
225
284
|
measured = await simulateForkPass(options.fork, planned, block, transactions, capGas, gasPrice, true, limits.headroomBps);
|
|
226
285
|
}
|
|
227
286
|
const failed = measured.find((step) => !step.success);
|
|
228
|
-
if (failed !== undefined) { await assertLifecycleBlock(client, block); return { ...base, backend, admitted: false, steps: measured, reason: failed.error, failedTransactionId: failed.transactionId }; }
|
|
229
|
-
const
|
|
230
|
-
const
|
|
287
|
+
if (failed !== undefined) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, backend, admitted: false, steps: measured, reason: failed.error, failedTransactionId: failed.transactionId }; }
|
|
288
|
+
const computeGas = measured.map((step) => buffer(step.gasRequired ?? step.gasUsed ?? computeCeiling));
|
|
289
|
+
const gasLimits = computeGas.map((gas, index) => gas + (posterGas[index] ?? 0n));
|
|
290
|
+
const exceeds = gasLimits.findIndex((gas, index) => gas <= 0n || gas >= 1n << 64n || (computeGas[index] ?? 0n) > limits.executionGasCeiling || limits.transactionGasCeiling !== undefined && gas > limits.transactionGasCeiling);
|
|
231
291
|
if (exceeds >= 0) {
|
|
232
|
-
await assertLifecycleBlock(client, block);
|
|
233
|
-
return { ...base, backend, admitted: false, steps: measured, reason: "Execution gas
|
|
292
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
293
|
+
return { ...base, executionProof: "unavailable", protocolFit: "failed", backend, admitted: false, steps: measured, reason: "Execution compute/headroom or complete poster gas envelope is outside positive uint64 gas and current protocol/known transaction limits", failedTransactionId: transactions[exceeds]?.id };
|
|
234
294
|
}
|
|
235
295
|
// Replay from the same initial state with exact headroom limits: gasUsed alone is not an EIP-150/gasleft proof.
|
|
236
296
|
// eth_simulateV1 with validation raises an RPC error on any revert (a generic
|
|
@@ -249,7 +309,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
249
309
|
} catch (failure) {
|
|
250
310
|
const message = failure instanceof Error ? failure.message : String(failure);
|
|
251
311
|
if (backend !== "eth_simulateV1") {
|
|
252
|
-
await assertLifecycleBlock(client, block);
|
|
312
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
253
313
|
return { ...base, backend, admitted: false, steps: [], reason: `Exact-gas validated replay failed: ${message}` };
|
|
254
314
|
}
|
|
255
315
|
validatedReplayError = message;
|
|
@@ -264,12 +324,12 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
264
324
|
// same pinned block; headroom is never silently raised — the carried gas is
|
|
265
325
|
// the validated envelope itself.
|
|
266
326
|
try {
|
|
267
|
-
const ceilingLimits =
|
|
327
|
+
const ceilingLimits = discoveryGas;
|
|
268
328
|
const retried = backend === "eth_simulateV1"
|
|
269
329
|
? await simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true)
|
|
270
330
|
: options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
|
|
271
331
|
if (retried === undefined || retried.some((step) => !step.success)) {
|
|
272
|
-
await assertLifecycleBlock(client, block);
|
|
332
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
273
333
|
return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling still failed: ${retried?.find((step) => !step.success)?.error ?? validatedReplayError}; final mint/lock/all-buys/public-opening is indivisible and will not be partitioned`, failedTransactionId: transactions[0]?.id };
|
|
274
334
|
}
|
|
275
335
|
admittedGasLimits = ceilingLimits;
|
|
@@ -277,7 +337,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
277
337
|
finalFailure = undefined;
|
|
278
338
|
validatedReplayError = undefined;
|
|
279
339
|
} catch {
|
|
280
|
-
await assertLifecycleBlock(client, block);
|
|
340
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
281
341
|
return { ...base, backend, admitted: false, steps: measured, reason: `Validated buffered replay failed (${validatedReplayError}) and envelope discovery at the exact gas ceiling was rejected by the simulation backend; final mint/lock/all-buys/public-opening is indivisible`, failedTransactionId: transactions[0]?.id };
|
|
282
342
|
}
|
|
283
343
|
}
|
|
@@ -285,11 +345,11 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
285
345
|
const failedIndex = verifiedSteps.findIndex((step) => !step.success);
|
|
286
346
|
const bufferedLimit = gasLimits[failedIndex];
|
|
287
347
|
const failedTransaction = transactions[failedIndex];
|
|
288
|
-
if (bufferedLimit !== undefined && failedTransaction !== undefined && looksGasExhausted(finalFailure, bufferedLimit) &&
|
|
348
|
+
if (bufferedLimit !== undefined && failedTransaction !== undefined && looksGasExhausted(finalFailure, bufferedLimit) && (discoveryGas[failedIndex] ?? 0n) > bufferedLimit) {
|
|
289
349
|
// Buffered replay returned an exhaustion-shaped failed step; learn the true
|
|
290
350
|
// validated envelope with one explicit replay at the exact execution ceiling.
|
|
291
351
|
try {
|
|
292
|
-
const ceilingLimits =
|
|
352
|
+
const ceilingLimits = discoveryGas;
|
|
293
353
|
const retried = backend === "eth_simulateV1"
|
|
294
354
|
? await simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true)
|
|
295
355
|
: options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
|
|
@@ -301,7 +361,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
301
361
|
finalFailure = undefined;
|
|
302
362
|
} else {
|
|
303
363
|
const indivisible = failedTransaction.kind === "activate" || failedTransaction.kind === "atomic";
|
|
304
|
-
await assertLifecycleBlock(client, block);
|
|
364
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
305
365
|
return { ...base, backend, admitted: false, steps: measured, reason: `Validated execution at the exact current gas ceiling still failed: ${retryFailure.error ?? "the true gas envelope exceeds the current execution limit"}${indivisible ? "; final mint/lock/all-buys/public-opening is indivisible and will not be partitioned" : ""}`, failedTransactionId: failedTransaction.id };
|
|
306
366
|
}
|
|
307
367
|
}
|
|
@@ -309,21 +369,25 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
309
369
|
}
|
|
310
370
|
}
|
|
311
371
|
const estimates: LifecycleSimulationStep[] = [];
|
|
312
|
-
const balance = rpcQuantity(await
|
|
372
|
+
const balance = rpcQuantity(await lifecyclePinnedRpc(client, "eth_getBalance", [planned.account, toHex(block.number)], block), "creator native balance");
|
|
313
373
|
let remainingBalance = balance;
|
|
314
374
|
let affordabilityFailure: string | undefined;
|
|
375
|
+
const feeContext = configuration.estimateDataFee === undefined || sourceClient === client ? context : { ...context, client: sourceClient };
|
|
315
376
|
for (let index = 0; index < verifiedSteps.length; index += 1) {
|
|
316
377
|
const step = verifiedSteps[index]; const transaction = transactions[index]; const gasLimit = admittedGasLimits[index];
|
|
317
378
|
if (step === undefined || transaction === undefined || gasLimit === undefined) throw new LifecyclePlanningError("INVALID_SIMULATION", "Missing verified fee context");
|
|
318
|
-
const dataFee = configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit },
|
|
319
|
-
if (dataFee !== undefined && dataFee < 0n) throw new LifecyclePlanningError("INVALID_DATA_FEE", "Data-fee oracle
|
|
379
|
+
const dataFee = limits.protocol === "nitro" ? 0n : configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit }, feeContext);
|
|
380
|
+
if (dataFee !== undefined && (typeof dataFee !== "bigint" || dataFee < 0n || dataFee >= 1n << 256n)) throw new LifecyclePlanningError("INVALID_DATA_FEE", "Data-fee oracle must return a nonnegative uint256 bigint amount");
|
|
381
|
+
// Nitro gas includes poster charges. Never add the quoted poster fee again.
|
|
320
382
|
const executionFee = gasLimit * gasPrice;
|
|
321
383
|
const required = transaction.value + executionFee + (dataFee ?? 0n);
|
|
384
|
+
if (required >= 1n << 256n) throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Transaction value and fee envelope exceed uint256");
|
|
322
385
|
if (required > remainingBalance && affordabilityFailure === undefined) affordabilityFailure = "Creator native balance cannot fund the remaining transaction value and execution/data fee envelopes";
|
|
323
386
|
remainingBalance -= required;
|
|
324
|
-
|
|
387
|
+
const poster = posterGas[index];
|
|
388
|
+
estimates.push({ ...step, estimate: { gasUsed: step.gasUsed ?? 0n, gasLimit, gasPrice, executionFee, dataFee, posterGas: poster, posterFee: poster === undefined ? undefined : poster * gasPrice, dataFeeIncludedInGas: limits.protocol === "nitro", totalFee: dataFee === undefined ? undefined : executionFee + dataFee, feeConfidence: dataFee === undefined ? "execution-only" : "execution-and-data" } });
|
|
325
389
|
}
|
|
326
|
-
await assertLifecycleBlock(client, block);
|
|
327
|
-
return { ...base, backend,
|
|
390
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
391
|
+
return { ...base, backend, executionProof: finalFailure === undefined ? "proved" : "failed", protocolFit: finalFailure === undefined ? "proved" : "unknown", admitted: finalFailure === undefined && affordabilityFailure === undefined, steps: estimates, reason: finalFailure?.error ?? affordabilityFailure, failedTransactionId: finalFailure?.transactionId };
|
|
328
392
|
}
|
|
329
393
|
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -7,21 +7,28 @@ import type {
|
|
|
7
7
|
/** viem public clients or a raw JSON-RPC client; neither selects a transport. */
|
|
8
8
|
export type LifecycleRpcClient = {
|
|
9
9
|
request(args: { method: string; params?: readonly unknown[] }): Promise<unknown>;
|
|
10
|
+
/** Opt in to bounded parallel reads only when the supplied transport batches concurrent requests. */
|
|
11
|
+
supportsReadBatching?: boolean;
|
|
10
12
|
};
|
|
11
13
|
export type LifecycleBlock = { number: bigint; hash: Hash; timestamp: bigint; gasLimit: bigint; baseFeePerGas?: bigint };
|
|
12
14
|
export type LifecycleLimitContext = { client: LifecycleRpcClient; account: Address; orchestrator: Address; block: LifecycleBlock; chainId: bigint };
|
|
13
15
|
export type LifecycleLimits = {
|
|
16
|
+
/** Optional tightening caps on the complete transaction gas envelope, including Nitro poster gas. */
|
|
14
17
|
headroomBps?: number; chainGasLimit?: bigint; rpcGasLimit?: bigint; accountGasLimit?: bigint;
|
|
15
18
|
maxCalldataBytes?: number; maxSimulationGas?: bigint;
|
|
16
19
|
chainId?: bigint; orchestrator?: Address; account?: Address; observedBlockNumber?: bigint; observedBlockHash?: Hash;
|
|
17
|
-
/**
|
|
20
|
+
/** External data fee not already charged in gas, called with exact calldata and the pinned block.
|
|
21
|
+
* Nitro poster fees are included in gas and use the native NodeInterface instead. */
|
|
18
22
|
estimateDataFee?: (transaction: LifecycleTransaction, context: LifecycleLimitContext) => Promise<bigint>;
|
|
19
23
|
};
|
|
20
24
|
export type LifecycleLimitSource = LifecycleLimits | ((context: LifecycleLimitContext) => Promise<LifecycleLimits>);
|
|
21
25
|
export type ResolvedLifecycleLimits = {
|
|
22
|
-
|
|
26
|
+
/** Generic EVM gas ceiling; on Nitro this is the pinned compute ceiling, not total gas. */
|
|
27
|
+
executionGasCeiling: bigint; transactionGasCeiling?: bigint; protocol: "evm" | "nitro";
|
|
28
|
+
arbOSVersion?: bigint; maxTxComputeGas?: bigint; maxBlockComputeGas?: bigint;
|
|
29
|
+
headroomBps: number; maxCalldataBytes?: number; maxSimulationGas?: bigint;
|
|
23
30
|
blockGasLimit: bigint; chainGasLimit?: bigint; rpcGasLimit?: bigint; accountGasLimit?: bigint;
|
|
24
|
-
|
|
31
|
+
unknownExecutionConstraints: readonly string[]; unknownConstraints: readonly string[];
|
|
25
32
|
};
|
|
26
33
|
/** Only explicitly disposable RPCs may execute snapshot-isolated simulation transactions. */
|
|
27
34
|
export type ControlledLifecycleFork = {
|
|
@@ -44,17 +51,26 @@ export type LifecycleTransaction = {
|
|
|
44
51
|
};
|
|
45
52
|
export type LifecycleTransactionEstimate = {
|
|
46
53
|
gasUsed: bigint; gasLimit: bigint; gasPrice: bigint; executionFee: bigint; dataFee?: bigint; totalFee?: bigint;
|
|
54
|
+
/** Estimated poster allocation within gasLimit, never an exact compute measurement or an extra fee. */
|
|
55
|
+
posterGas?: bigint; posterFee?: bigint; dataFeeIncludedInGas: boolean;
|
|
47
56
|
feeConfidence: "execution-and-data" | "execution-only";
|
|
48
57
|
};
|
|
49
|
-
|
|
58
|
+
/** Proof scopes are independent: current execution is not guaranteed wallet submission. */
|
|
59
|
+
export type LifecycleProofOutcomes = {
|
|
60
|
+
executionProof: "proved" | "failed" | "unavailable";
|
|
61
|
+
protocolFit: "proved" | "failed" | "unknown";
|
|
62
|
+
transportPreflight: "not-requested" | "passed" | "failed";
|
|
63
|
+
};
|
|
64
|
+
export type LifecycleAdmission = LifecycleProofOutcomes & {
|
|
50
65
|
admitted: boolean; confidence: "stateful" | "provisional";
|
|
66
|
+
blockNumber: bigint; blockHash: Hash; account: Address; chainId: bigint;
|
|
51
67
|
reason?: string; limits: ResolvedLifecycleLimits;
|
|
52
68
|
};
|
|
53
69
|
export type LifecycleSimulationStep = {
|
|
54
70
|
transactionId: string; success: boolean; gasUsed?: bigint; gasLimit?: bigint; gasRequired?: bigint;
|
|
55
71
|
returnData?: Hex; error?: string; estimate?: LifecycleTransactionEstimate;
|
|
56
72
|
};
|
|
57
|
-
export type LifecycleSimulation = {
|
|
73
|
+
export type LifecycleSimulation = LifecycleProofOutcomes & {
|
|
58
74
|
backend: "eth_simulateV1" | "controlled-fork" | "unavailable";
|
|
59
75
|
confidence: "stateful" | "provisional"; admitted: boolean;
|
|
60
76
|
blockNumber: bigint; blockHash: Hash; account: Address; chainId: bigint;
|
|
@@ -122,6 +138,8 @@ export type ReadLaunchProgressOptions = {
|
|
|
122
138
|
};
|
|
123
139
|
export type BuildNextTransactionOptions = ReadLaunchProgressOptions & {
|
|
124
140
|
planned: PlannedLaunch; action?: "continue" | "cancel"; limits?: LifecycleLimitSource; fork?: ControlledLifecycleFork;
|
|
141
|
+
/** Optional active submission RPC. Only the immediate next exact transaction is estimated, read-only. */
|
|
142
|
+
submissionClient?: LifecycleRpcClient;
|
|
125
143
|
};
|
|
126
144
|
export class LifecyclePlanningError extends Error {
|
|
127
145
|
readonly code: string;
|