@black-market/sdk 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/lifecycle/planner.d.ts.map +1 -1
- package/dist/lifecycle/planner.js +45 -10
- package/dist/lifecycle/planner.js.map +1 -1
- package/dist/lifecycle/rpc.d.ts +37 -2
- package/dist/lifecycle/rpc.d.ts.map +1 -1
- package/dist/lifecycle/rpc.js +84 -26
- package/dist/lifecycle/rpc.js.map +1 -1
- package/dist/lifecycle/simulation.d.ts.map +1 -1
- package/dist/lifecycle/simulation.js +102 -30
- package/dist/lifecycle/simulation.js.map +1 -1
- package/dist/lifecycle/types.d.ts +27 -4
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lifecycle/types.js.map +1 -1
- package/docs/launch-lifecycle.md +126 -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 +37 -9
- package/src/lifecycle/rpc.ts +72 -25
- package/src/lifecycle/simulation.ts +90 -28
- package/src/lifecycle/types.ts +21 -5
package/src/lifecycle/rpc.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
|
@@ -31,7 +31,8 @@ export async function readLifecycleBlock(client: LifecycleRpcClient, tag: Hex |
|
|
|
31
31
|
baseFeePerGas: value.baseFeePerGas === undefined || value.baseFeePerGas === null ? undefined : rpcQuantity(value.baseFeePerGas, "base fee"),
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
-
export async function assertLifecycleBlock(client: LifecycleRpcClient, block: LifecycleBlock): Promise<void> {
|
|
34
|
+
export async function assertLifecycleBlock(client: LifecycleRpcClient, block: LifecycleBlock, expectedChainId?: bigint): Promise<void> {
|
|
35
|
+
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
36
|
const current = await readLifecycleBlock(client, toHex(block.number));
|
|
36
37
|
if (current.hash.toLowerCase() !== block.hash.toLowerCase()) throw new LifecyclePlanningError("STATE_REORGED", "Pinned launch state changed during the operation; read canonical progress again");
|
|
37
38
|
}
|
|
@@ -49,40 +50,86 @@ export async function readLifecycleProfileTopology(client: LifecycleRpcClient, r
|
|
|
49
50
|
if (topology.hookTopology !== 0 && topology.hookTopology !== 1 && topology.hookTopology !== 2) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Registry returned an unsupported hook topology");
|
|
50
51
|
return { ...topology, hookTopology: topology.hookTopology };
|
|
51
52
|
}
|
|
53
|
+
export const nitroArbSys = "0x0000000000000000000000000000000000000064";
|
|
54
|
+
export const nitroGasInfo = "0x000000000000000000000000000000000000006c";
|
|
55
|
+
export const nitroNodeInterface = "0x00000000000000000000000000000000000000c8";
|
|
56
|
+
export const nitroArbSysAbi = parseAbi(["function arbOSVersion() view returns (uint256)"]);
|
|
57
|
+
export const nitroGasInfoAbi = parseAbi(["function getMaxTxGasLimit() view returns (uint256)", "function getMaxBlockGasLimit() view returns (uint64)"]);
|
|
58
|
+
const nitroNodeInterfaceAbi = parseAbi(["function gasEstimateL1Component(address to, bool contractCreation, bytes data) payable returns (uint64 gasEstimateForL1, uint256 baseFee, uint256 l1BaseFeeEstimate)"]);
|
|
59
|
+
const maxUint64 = (1n << 64n) - 1n;
|
|
60
|
+
|
|
61
|
+
/** A poster quote is an envelope budget, never an execution/compute proof. */
|
|
62
|
+
export async function readNitroPosterGas(transaction: LifecycleTransaction, context: LifecycleLimitContext): Promise<{ posterGas: bigint; baseFee: bigint; l1BaseFeeEstimate: bigint }> {
|
|
63
|
+
const data = encodeFunctionData({ abi: nitroNodeInterfaceAbi, functionName: "gasEstimateL1Component", args: [transaction.to, false, transaction.data] });
|
|
64
|
+
const result = rpcHex(await lifecycleRpc(context.client, "eth_call", [{ from: transaction.from, to: nitroNodeInterface, data, value: toHex(transaction.value) }, toHex(context.block.number)]), "Nitro poster estimate");
|
|
65
|
+
if (result.length !== 194) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Nitro poster estimate must contain exactly three ABI words");
|
|
66
|
+
const [posterGas, baseFee, l1BaseFeeEstimate] = decodeFunctionResult({ abi: nitroNodeInterfaceAbi, functionName: "gasEstimateL1Component", data: result });
|
|
67
|
+
if (posterGas < 0n || posterGas > maxUint64 || baseFee <= 0n || l1BaseFeeEstimate < 0n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Nitro poster estimate has invalid gas or fee values");
|
|
68
|
+
return { posterGas, baseFee, l1BaseFeeEstimate };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readNitroScalar(context: LifecycleLimitContext, to: Address, abi: Abi, functionName: string): Promise<bigint> {
|
|
72
|
+
const data = encodeFunctionData({ abi, functionName });
|
|
73
|
+
const result = rpcHex(await lifecycleRpc(context.client, "eth_call", [{ to, data }, toHex(context.block.number)]), functionName);
|
|
74
|
+
if (result.length !== 66) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", `${functionName} must return exactly one ABI word`);
|
|
75
|
+
return decodeFunctionResult({ abi, functionName, data: result }) as bigint;
|
|
76
|
+
}
|
|
77
|
+
|
|
52
78
|
export async function resolveLifecycleLimits(context: LifecycleLimitContext, source?: LifecycleLimitSource): Promise<{ limits: ResolvedLifecycleLimits; configuration: LifecycleLimits }> {
|
|
53
|
-
let configuration: LifecycleLimits
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const headroomBps = configuration.headroomBps
|
|
79
|
+
let configuration: LifecycleLimits;
|
|
80
|
+
try { configuration = source === undefined ? {} : typeof source === "function" ? await source(context) : source; }
|
|
81
|
+
catch (failure) { throw new LifecyclePlanningError("LIMIT_SOURCE_FAILED", `Supplied execution policy could not be resolved: ${failure instanceof Error ? failure.message : String(failure)}`); }
|
|
82
|
+
if (configuration === null || typeof configuration !== "object" || Array.isArray(configuration)) throw new LifecyclePlanningError("INVALID_LIMITS", "Supplied execution policy must be an object");
|
|
83
|
+
const headroomBps = configuration.headroomBps === undefined ? 1500 : configuration.headroomBps;
|
|
58
84
|
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
|
-
|
|
85
|
+
for (const limit of [context.block.gasLimit, configuration.chainGasLimit, configuration.rpcGasLimit, configuration.accountGasLimit, configuration.maxSimulationGas]) {
|
|
86
|
+
if (limit !== undefined && (typeof limit !== "bigint" || limit <= 0n || limit > maxUint64)) throw new LifecyclePlanningError("INVALID_LIMITS", "Gas caps must be positive exact uint64 bigint values");
|
|
87
|
+
}
|
|
61
88
|
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.
|
|
89
|
+
if (configuration.estimateDataFee !== undefined && typeof configuration.estimateDataFee !== "function") throw new LifecyclePlanningError("INVALID_LIMITS", "External data-fee estimator must be a function");
|
|
90
|
+
if (configuration.chainId !== undefined && (typeof configuration.chainId !== "bigint" || configuration.chainId !== context.chainId) ||
|
|
91
|
+
configuration.observedBlockNumber !== undefined && (typeof configuration.observedBlockNumber !== "bigint" || configuration.observedBlockNumber !== context.block.number) ||
|
|
92
|
+
configuration.observedBlockHash !== undefined && (typeof configuration.observedBlockHash !== "string" || !/^0x[\da-fA-F]{64}$/.test(configuration.observedBlockHash) || configuration.observedBlockHash.toLowerCase() !== context.block.hash.toLowerCase()) ||
|
|
93
|
+
configuration.account !== undefined && (typeof configuration.account !== "string" || !isAddress(configuration.account) || configuration.account.toLowerCase() !== context.account.toLowerCase()) ||
|
|
94
|
+
configuration.orchestrator !== undefined && (typeof configuration.orchestrator !== "string" || !isAddress(configuration.orchestrator) || configuration.orchestrator.toLowerCase() !== context.orchestrator.toLowerCase())) {
|
|
95
|
+
throw new LifecyclePlanningError("LIMIT_CONTEXT_MISMATCH", "Supplied execution policy provenance differs from the canonical chain/account/core/block context");
|
|
96
|
+
}
|
|
97
|
+
const protocol = context.chainId === 4663n ? "nitro" : "evm";
|
|
98
|
+
let arbOSVersion: bigint | undefined; let maxTxComputeGas: bigint | undefined; let maxBlockComputeGas: bigint | undefined;
|
|
99
|
+
if (protocol === "nitro") {
|
|
100
|
+
try {
|
|
101
|
+
const rawVersion = await readNitroScalar(context, nitroArbSys, nitroArbSysAbi, "arbOSVersion");
|
|
102
|
+
// ArbSys alone adds 55 to the logical Nitro ArbOS version.
|
|
103
|
+
if (typeof rawVersion !== "bigint" || rawVersion < 105n) throw new Error("ArbOS version 50 or newer is required (ArbSys raw version >= 105)");
|
|
104
|
+
arbOSVersion = rawVersion - 55n;
|
|
105
|
+
maxTxComputeGas = await readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxTxGasLimit");
|
|
106
|
+
maxBlockComputeGas = await readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxBlockGasLimit");
|
|
107
|
+
if ([maxTxComputeGas, maxBlockComputeGas].some((limit) => typeof limit !== "bigint" || limit <= 0n || limit > maxUint64)) throw new Error("Nitro compute getters must be positive uint64 values");
|
|
108
|
+
} 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`); }
|
|
109
|
+
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");
|
|
110
|
+
}
|
|
111
|
+
const envelopeCaps = [configuration.chainGasLimit, configuration.rpcGasLimit, configuration.accountGasLimit].filter((limit): limit is bigint => limit !== undefined);
|
|
112
|
+
if (protocol === "evm") envelopeCaps.push(context.block.gasLimit);
|
|
113
|
+
const transactionGasCeiling = envelopeCaps.length === 0 ? undefined : envelopeCaps.reduce((minimum, limit) => limit < minimum ? limit : minimum);
|
|
114
|
+
const executionGasCeiling = maxTxComputeGas !== undefined && maxBlockComputeGas !== undefined
|
|
115
|
+
? (maxTxComputeGas < maxBlockComputeGas ? maxTxComputeGas : maxBlockComputeGas)
|
|
116
|
+
: transactionGasCeiling ?? context.block.gasLimit;
|
|
63
117
|
const unknownExecutionConstraints = [
|
|
64
|
-
...(configuration.chainGasLimit === undefined ? ["
|
|
65
|
-
...(configuration.rpcGasLimit === undefined ? ["RPC transaction
|
|
66
|
-
...(configuration.accountGasLimit === undefined ? ["Current account/wallet transaction
|
|
118
|
+
...(protocol === "evm" && configuration.chainGasLimit === undefined ? ["Independent chain per-transaction execution cap is unknown; proof is bounded by the pinned header and validated backend"] : []),
|
|
119
|
+
...(configuration.rpcGasLimit === undefined ? ["RPC transaction gas-envelope cap is unknown"] : []),
|
|
120
|
+
...(configuration.accountGasLimit === undefined ? ["Current account/wallet transaction gas-envelope cap is unknown"] : []),
|
|
67
121
|
...(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}`]),
|
|
122
|
+
...(configuration.maxSimulationGas === undefined ? ["RPC aggregate simulation gas cap is unknown"] : []),
|
|
73
123
|
];
|
|
74
124
|
return {
|
|
75
125
|
configuration,
|
|
76
126
|
limits: {
|
|
77
|
-
executionGasCeiling
|
|
127
|
+
protocol, executionGasCeiling, transactionGasCeiling, arbOSVersion, maxTxComputeGas, maxBlockComputeGas, headroomBps,
|
|
78
128
|
maxCalldataBytes: configuration.maxCalldataBytes, maxSimulationGas: configuration.maxSimulationGas,
|
|
79
129
|
blockGasLimit: context.block.gasLimit, chainGasLimit: configuration.chainGasLimit,
|
|
80
130
|
rpcGasLimit: configuration.rpcGasLimit, accountGasLimit: configuration.accountGasLimit,
|
|
81
|
-
|
|
82
|
-
unknownConstraints: [
|
|
83
|
-
...unknownExecutionConstraints,
|
|
84
|
-
...(configuration.estimateDataFee === undefined ? ["Chain-specific data fee is unavailable"] : []),
|
|
85
|
-
],
|
|
131
|
+
unknownExecutionConstraints,
|
|
132
|
+
unknownConstraints: [...unknownExecutionConstraints, ...(protocol === "evm" && configuration.estimateDataFee === undefined ? ["External chain-specific data fee is unavailable"] : [])],
|
|
86
133
|
},
|
|
87
134
|
};
|
|
88
135
|
}
|
|
@@ -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, lifecycleRpc, 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;
|
|
@@ -201,36 +237,59 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
201
237
|
if (options.fork?.client === client) throw new LifecyclePlanningError("UNSAFE_FORK", "Source RPC must never receive controlled simulation transactions/snapshots/resets");
|
|
202
238
|
const block = pinnedBlock ?? await readLifecycleBlock(client);
|
|
203
239
|
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 };
|
|
240
|
+
const { limits, configuration } = await resolveLifecycleLimits(context, options.limits === undefined ? planned.limits : options.limits);
|
|
241
|
+
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
242
|
if (rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID") !== planned.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain changed before sequential simulation");
|
|
207
243
|
const accountCode = rpcHex(await lifecycleRpc(client, "eth_getCode", [planned.account, toHex(block.number)]), "account code");
|
|
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
|
+
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
245
|
for (const transaction of transactions) {
|
|
210
|
-
if (
|
|
246
|
+
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");
|
|
247
|
+
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
248
|
}
|
|
212
|
-
if (transactions.length === 0) { await assertLifecycleBlock(client, block); return { ...base, backend: "unavailable", admitted: true, steps: [] }; }
|
|
249
|
+
if (transactions.length === 0) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, executionProof: "proved", protocolFit: "proved", backend: "unavailable", admitted: true, steps: [] }; }
|
|
213
250
|
const gasPrice = rpcQuantity(await lifecycleRpc(client, "eth_gasPrice"), "gas price");
|
|
214
|
-
|
|
251
|
+
if (gasPrice >= 1n << 256n) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Gas price must be a uint256 quantity");
|
|
252
|
+
const buffer = (gas: bigint) => (gas * BigInt(10000 + limits.headroomBps) + 9999n) / 10000n;
|
|
253
|
+
const posterGas: bigint[] = [];
|
|
254
|
+
if (limits.protocol === "nitro") {
|
|
255
|
+
try {
|
|
256
|
+
await proveNitroBackend(client, planned, block, limits, gasPrice);
|
|
257
|
+
for (const transaction of transactions) posterGas.push(buffer((await readNitroPosterGas(transaction, context)).posterGas));
|
|
258
|
+
} catch (failure) {
|
|
259
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
260
|
+
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.` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const computeCeiling = limits.transactionGasCeiling !== undefined && limits.transactionGasCeiling < limits.executionGasCeiling ? limits.transactionGasCeiling : limits.executionGasCeiling;
|
|
264
|
+
const capGas = transactions.map(() => computeCeiling);
|
|
265
|
+
// The poster quote allocates gas only. The native exact replay enforces actual
|
|
266
|
+
// compute hold; subtracting this approximate budget from gasUsed would not.
|
|
267
|
+
const discoveryGas = transactions.map((_, index) => {
|
|
268
|
+
const total = limits.executionGasCeiling + (posterGas[index] ?? 0n);
|
|
269
|
+
const capped = limits.transactionGasCeiling !== undefined && limits.transactionGasCeiling < total ? limits.transactionGasCeiling : total;
|
|
270
|
+
if (capped <= 0n || capped >= 1n << 64n) throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Compute/poster envelope exceeds uint64 gas");
|
|
271
|
+
return capped;
|
|
272
|
+
});
|
|
215
273
|
let backend: "eth_simulateV1" | "controlled-fork" = "eth_simulateV1";
|
|
216
274
|
let measured: LifecycleSimulationStep[];
|
|
217
275
|
// Permissive cap measurement is not proof; only the exact-gas validated replay admits a plan.
|
|
218
276
|
try { measured = await simulateRpcPass(client, planned, block, transactions, capGas, limits, gasPrice, false); }
|
|
219
277
|
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
|
|
278
|
+
if (options.fork === undefined || limits.protocol === "nitro") {
|
|
279
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
280
|
+
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
281
|
}
|
|
224
282
|
backend = "controlled-fork";
|
|
225
283
|
measured = await simulateForkPass(options.fork, planned, block, transactions, capGas, gasPrice, true, limits.headroomBps);
|
|
226
284
|
}
|
|
227
285
|
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
|
|
286
|
+
if (failed !== undefined) { await assertLifecycleBlock(client, block, planned.chainId); return { ...base, backend, admitted: false, steps: measured, reason: failed.error, failedTransactionId: failed.transactionId }; }
|
|
287
|
+
const computeGas = measured.map((step) => buffer(step.gasRequired ?? step.gasUsed ?? computeCeiling));
|
|
288
|
+
const gasLimits = computeGas.map((gas, index) => gas + (posterGas[index] ?? 0n));
|
|
289
|
+
const exceeds = gasLimits.findIndex((gas, index) => gas <= 0n || gas >= 1n << 64n || (computeGas[index] ?? 0n) > limits.executionGasCeiling || limits.transactionGasCeiling !== undefined && gas > limits.transactionGasCeiling);
|
|
231
290
|
if (exceeds >= 0) {
|
|
232
|
-
await assertLifecycleBlock(client, block);
|
|
233
|
-
return { ...base, backend, admitted: false, steps: measured, reason: "Execution gas
|
|
291
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
292
|
+
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
293
|
}
|
|
235
294
|
// Replay from the same initial state with exact headroom limits: gasUsed alone is not an EIP-150/gasleft proof.
|
|
236
295
|
// eth_simulateV1 with validation raises an RPC error on any revert (a generic
|
|
@@ -249,7 +308,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
249
308
|
} catch (failure) {
|
|
250
309
|
const message = failure instanceof Error ? failure.message : String(failure);
|
|
251
310
|
if (backend !== "eth_simulateV1") {
|
|
252
|
-
await assertLifecycleBlock(client, block);
|
|
311
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
253
312
|
return { ...base, backend, admitted: false, steps: [], reason: `Exact-gas validated replay failed: ${message}` };
|
|
254
313
|
}
|
|
255
314
|
validatedReplayError = message;
|
|
@@ -264,12 +323,12 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
264
323
|
// same pinned block; headroom is never silently raised — the carried gas is
|
|
265
324
|
// the validated envelope itself.
|
|
266
325
|
try {
|
|
267
|
-
const ceilingLimits =
|
|
326
|
+
const ceilingLimits = discoveryGas;
|
|
268
327
|
const retried = backend === "eth_simulateV1"
|
|
269
328
|
? await simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true)
|
|
270
329
|
: options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
|
|
271
330
|
if (retried === undefined || retried.some((step) => !step.success)) {
|
|
272
|
-
await assertLifecycleBlock(client, block);
|
|
331
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
273
332
|
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
333
|
}
|
|
275
334
|
admittedGasLimits = ceilingLimits;
|
|
@@ -277,7 +336,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
277
336
|
finalFailure = undefined;
|
|
278
337
|
validatedReplayError = undefined;
|
|
279
338
|
} catch {
|
|
280
|
-
await assertLifecycleBlock(client, block);
|
|
339
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
281
340
|
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
341
|
}
|
|
283
342
|
}
|
|
@@ -285,11 +344,11 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
285
344
|
const failedIndex = verifiedSteps.findIndex((step) => !step.success);
|
|
286
345
|
const bufferedLimit = gasLimits[failedIndex];
|
|
287
346
|
const failedTransaction = transactions[failedIndex];
|
|
288
|
-
if (bufferedLimit !== undefined && failedTransaction !== undefined && looksGasExhausted(finalFailure, bufferedLimit) &&
|
|
347
|
+
if (bufferedLimit !== undefined && failedTransaction !== undefined && looksGasExhausted(finalFailure, bufferedLimit) && (discoveryGas[failedIndex] ?? 0n) > bufferedLimit) {
|
|
289
348
|
// Buffered replay returned an exhaustion-shaped failed step; learn the true
|
|
290
349
|
// validated envelope with one explicit replay at the exact execution ceiling.
|
|
291
350
|
try {
|
|
292
|
-
const ceilingLimits =
|
|
351
|
+
const ceilingLimits = discoveryGas;
|
|
293
352
|
const retried = backend === "eth_simulateV1"
|
|
294
353
|
? await simulateRpcPass(client, planned, block, transactions, ceilingLimits, limits, gasPrice, true)
|
|
295
354
|
: options.fork === undefined ? undefined : await simulateForkPass(options.fork, planned, block, transactions, ceilingLimits, gasPrice, false, limits.headroomBps);
|
|
@@ -301,7 +360,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
301
360
|
finalFailure = undefined;
|
|
302
361
|
} else {
|
|
303
362
|
const indivisible = failedTransaction.kind === "activate" || failedTransaction.kind === "atomic";
|
|
304
|
-
await assertLifecycleBlock(client, block);
|
|
363
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
305
364
|
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
365
|
}
|
|
307
366
|
}
|
|
@@ -315,15 +374,18 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
|
|
|
315
374
|
for (let index = 0; index < verifiedSteps.length; index += 1) {
|
|
316
375
|
const step = verifiedSteps[index]; const transaction = transactions[index]; const gasLimit = admittedGasLimits[index];
|
|
317
376
|
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 }, context);
|
|
319
|
-
if (dataFee !== undefined && dataFee < 0n) throw new LifecyclePlanningError("INVALID_DATA_FEE", "Data-fee oracle
|
|
377
|
+
const dataFee = limits.protocol === "nitro" ? 0n : configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit }, context);
|
|
378
|
+
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");
|
|
379
|
+
// Nitro gas includes poster charges. Never add the quoted poster fee again.
|
|
320
380
|
const executionFee = gasLimit * gasPrice;
|
|
321
381
|
const required = transaction.value + executionFee + (dataFee ?? 0n);
|
|
382
|
+
if (required >= 1n << 256n) throw new LifecyclePlanningError("INVALID_GAS_ENVELOPE", "Transaction value and fee envelope exceed uint256");
|
|
322
383
|
if (required > remainingBalance && affordabilityFailure === undefined) affordabilityFailure = "Creator native balance cannot fund the remaining transaction value and execution/data fee envelopes";
|
|
323
384
|
remainingBalance -= required;
|
|
324
|
-
|
|
385
|
+
const poster = posterGas[index];
|
|
386
|
+
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
387
|
}
|
|
326
|
-
await assertLifecycleBlock(client, block);
|
|
327
|
-
return { ...base, backend,
|
|
388
|
+
await assertLifecycleBlock(client, block, planned.chainId);
|
|
389
|
+
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
390
|
}
|
|
329
391
|
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -11,17 +11,22 @@ export type LifecycleRpcClient = {
|
|
|
11
11
|
export type LifecycleBlock = { number: bigint; hash: Hash; timestamp: bigint; gasLimit: bigint; baseFeePerGas?: bigint };
|
|
12
12
|
export type LifecycleLimitContext = { client: LifecycleRpcClient; account: Address; orchestrator: Address; block: LifecycleBlock; chainId: bigint };
|
|
13
13
|
export type LifecycleLimits = {
|
|
14
|
+
/** Optional tightening caps on the complete transaction gas envelope, including Nitro poster gas. */
|
|
14
15
|
headroomBps?: number; chainGasLimit?: bigint; rpcGasLimit?: bigint; accountGasLimit?: bigint;
|
|
15
16
|
maxCalldataBytes?: number; maxSimulationGas?: bigint;
|
|
16
17
|
chainId?: bigint; orchestrator?: Address; account?: Address; observedBlockNumber?: bigint; observedBlockHash?: Hash;
|
|
17
|
-
/**
|
|
18
|
+
/** External data fee not already charged in gas, called with exact calldata and the pinned block.
|
|
19
|
+
* Nitro poster fees are included in gas and use the native NodeInterface instead. */
|
|
18
20
|
estimateDataFee?: (transaction: LifecycleTransaction, context: LifecycleLimitContext) => Promise<bigint>;
|
|
19
21
|
};
|
|
20
22
|
export type LifecycleLimitSource = LifecycleLimits | ((context: LifecycleLimitContext) => Promise<LifecycleLimits>);
|
|
21
23
|
export type ResolvedLifecycleLimits = {
|
|
22
|
-
|
|
24
|
+
/** Generic EVM gas ceiling; on Nitro this is the pinned compute ceiling, not total gas. */
|
|
25
|
+
executionGasCeiling: bigint; transactionGasCeiling?: bigint; protocol: "evm" | "nitro";
|
|
26
|
+
arbOSVersion?: bigint; maxTxComputeGas?: bigint; maxBlockComputeGas?: bigint;
|
|
27
|
+
headroomBps: number; maxCalldataBytes?: number; maxSimulationGas?: bigint;
|
|
23
28
|
blockGasLimit: bigint; chainGasLimit?: bigint; rpcGasLimit?: bigint; accountGasLimit?: bigint;
|
|
24
|
-
|
|
29
|
+
unknownExecutionConstraints: readonly string[]; unknownConstraints: readonly string[];
|
|
25
30
|
};
|
|
26
31
|
/** Only explicitly disposable RPCs may execute snapshot-isolated simulation transactions. */
|
|
27
32
|
export type ControlledLifecycleFork = {
|
|
@@ -44,17 +49,26 @@ export type LifecycleTransaction = {
|
|
|
44
49
|
};
|
|
45
50
|
export type LifecycleTransactionEstimate = {
|
|
46
51
|
gasUsed: bigint; gasLimit: bigint; gasPrice: bigint; executionFee: bigint; dataFee?: bigint; totalFee?: bigint;
|
|
52
|
+
/** Estimated poster allocation within gasLimit, never an exact compute measurement or an extra fee. */
|
|
53
|
+
posterGas?: bigint; posterFee?: bigint; dataFeeIncludedInGas: boolean;
|
|
47
54
|
feeConfidence: "execution-and-data" | "execution-only";
|
|
48
55
|
};
|
|
49
|
-
|
|
56
|
+
/** Proof scopes are independent: current execution is not guaranteed wallet submission. */
|
|
57
|
+
export type LifecycleProofOutcomes = {
|
|
58
|
+
executionProof: "proved" | "failed" | "unavailable";
|
|
59
|
+
protocolFit: "proved" | "failed" | "unknown";
|
|
60
|
+
transportPreflight: "not-requested" | "passed" | "failed";
|
|
61
|
+
};
|
|
62
|
+
export type LifecycleAdmission = LifecycleProofOutcomes & {
|
|
50
63
|
admitted: boolean; confidence: "stateful" | "provisional";
|
|
64
|
+
blockNumber: bigint; blockHash: Hash; account: Address; chainId: bigint;
|
|
51
65
|
reason?: string; limits: ResolvedLifecycleLimits;
|
|
52
66
|
};
|
|
53
67
|
export type LifecycleSimulationStep = {
|
|
54
68
|
transactionId: string; success: boolean; gasUsed?: bigint; gasLimit?: bigint; gasRequired?: bigint;
|
|
55
69
|
returnData?: Hex; error?: string; estimate?: LifecycleTransactionEstimate;
|
|
56
70
|
};
|
|
57
|
-
export type LifecycleSimulation = {
|
|
71
|
+
export type LifecycleSimulation = LifecycleProofOutcomes & {
|
|
58
72
|
backend: "eth_simulateV1" | "controlled-fork" | "unavailable";
|
|
59
73
|
confidence: "stateful" | "provisional"; admitted: boolean;
|
|
60
74
|
blockNumber: bigint; blockHash: Hash; account: Address; chainId: bigint;
|
|
@@ -122,6 +136,8 @@ export type ReadLaunchProgressOptions = {
|
|
|
122
136
|
};
|
|
123
137
|
export type BuildNextTransactionOptions = ReadLaunchProgressOptions & {
|
|
124
138
|
planned: PlannedLaunch; action?: "continue" | "cancel"; limits?: LifecycleLimitSource; fork?: ControlledLifecycleFork;
|
|
139
|
+
/** Optional active submission RPC. Only the immediate next exact transaction is estimated, read-only. */
|
|
140
|
+
submissionClient?: LifecycleRpcClient;
|
|
125
141
|
};
|
|
126
142
|
export class LifecyclePlanningError extends Error {
|
|
127
143
|
readonly code: string;
|