@black-market/sdk 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,81 @@ import { LifecyclePlanningError, type LifecycleBlock, type LifecycleLimitContext
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`);
@@ -38,7 +111,11 @@ export async function assertLifecycleBlock(client: LifecycleRpcClient, block: Li
38
111
  }
39
112
  export async function readLifecycleContract<T>(client: LifecycleRpcClient, to: Address, abi: Abi, functionName: string, args: readonly unknown[], block: LifecycleBlock, from?: Address): Promise<T> {
40
113
  const data = encodeFunctionData({ abi, functionName, args });
41
- const result = rpcHex(await lifecycleRpc(client, "eth_call", [{ to, from, data }, toHex(block.number)]), functionName);
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);
42
119
  // ABI decoding validates scalar widths and tuple layout; T names that exact frozen ABI result.
43
120
  const decoded = decodeFunctionResult({ abi, functionName, data: result }) as T;
44
121
  return decoded;
@@ -61,7 +138,7 @@ const maxUint64 = (1n << 64n) - 1n;
61
138
  /** A poster quote is an envelope budget, never an execution/compute proof. */
62
139
  export async function readNitroPosterGas(transaction: LifecycleTransaction, context: LifecycleLimitContext): Promise<{ posterGas: bigint; baseFee: bigint; l1BaseFeeEstimate: bigint }> {
63
140
  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");
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");
65
142
  if (result.length !== 194) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", "Nitro poster estimate must contain exactly three ABI words");
66
143
  const [posterGas, baseFee, l1BaseFeeEstimate] = decodeFunctionResult({ abi: nitroNodeInterfaceAbi, functionName: "gasEstimateL1Component", data: result });
67
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");
@@ -70,14 +147,15 @@ export async function readNitroPosterGas(transaction: LifecycleTransaction, cont
70
147
 
71
148
  async function readNitroScalar(context: LifecycleLimitContext, to: Address, abi: Abi, functionName: string): Promise<bigint> {
72
149
  const data = encodeFunctionData({ abi, functionName });
73
- const result = rpcHex(await lifecycleRpc(context.client, "eth_call", [{ to, data }, toHex(context.block.number)]), functionName);
150
+ const result = rpcHex(await lifecyclePinnedRpc(context.client, "eth_call", [{ to, data }, toHex(context.block.number)], context.block), functionName);
74
151
  if (result.length !== 66) throw new LifecyclePlanningError("INVALID_RPC_RESPONSE", `${functionName} must return exactly one ABI word`);
75
152
  return decodeFunctionResult({ abi, functionName, data: result }) as bigint;
76
153
  }
77
154
 
78
155
  export async function resolveLifecycleLimits(context: LifecycleLimitContext, source?: LifecycleLimitSource): Promise<{ limits: ResolvedLifecycleLimits; configuration: LifecycleLimits }> {
79
156
  let configuration: LifecycleLimits;
80
- try { configuration = source === undefined ? {} : typeof source === "function" ? await source(context) : source; }
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; }
81
159
  catch (failure) { throw new LifecyclePlanningError("LIMIT_SOURCE_FAILED", `Supplied execution policy could not be resolved: ${failure instanceof Error ? failure.message : String(failure)}`); }
82
160
  if (configuration === null || typeof configuration !== "object" || Array.isArray(configuration)) throw new LifecyclePlanningError("INVALID_LIMITS", "Supplied execution policy must be an object");
83
161
  const headroomBps = configuration.headroomBps === undefined ? 1500 : configuration.headroomBps;
@@ -98,12 +176,16 @@ export async function resolveLifecycleLimits(context: LifecycleLimitContext, sou
98
176
  let arbOSVersion: bigint | undefined; let maxTxComputeGas: bigint | undefined; let maxBlockComputeGas: bigint | undefined;
99
177
  if (protocol === "nitro") {
100
178
  try {
101
- const rawVersion = await readNitroScalar(context, nitroArbSys, nitroArbSysAbi, "arbOSVersion");
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
+ ]);
102
184
  // ArbSys alone adds 55 to the logical Nitro ArbOS version.
103
185
  if (typeof rawVersion !== "bigint" || rawVersion < 105n) throw new Error("ArbOS version 50 or newer is required (ArbSys raw version >= 105)");
104
186
  arbOSVersion = rawVersion - 55n;
105
- maxTxComputeGas = await readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxTxGasLimit");
106
- maxBlockComputeGas = await readNitroScalar(context, nitroGasInfo, nitroGasInfoAbi, "getMaxBlockGasLimit");
187
+ maxTxComputeGas = txComputeGas;
188
+ maxBlockComputeGas = blockComputeGas;
107
189
  if ([maxTxComputeGas, maxBlockComputeGas].some((limit) => typeof limit !== "bigint" || limit <= 0n || limit > maxUint64)) throw new Error("Nitro compute getters must be positive uint64 values");
108
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`); }
109
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");
@@ -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, nitroArbSys, nitroArbSysAbi, nitroGasInfo, nitroGasInfoAbi, readLifecycleBlock, readNitroPosterGas, 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 } {
@@ -234,13 +234,14 @@ async function simulateForkPass(fork: ControlledLifecycleFork, planned: PlannedL
234
234
 
235
235
  export async function simulateLaunchTransactions(options: SimulateLaunchPlanOptions, transactions: readonly LifecycleTransaction[], pinnedBlock?: LifecycleBlock): Promise<LifecycleSimulation> {
236
236
  const { client, planned } = options;
237
- if (options.fork?.client === client) throw new LifecyclePlanningError("UNSAFE_FORK", "Source RPC must never receive controlled simulation transactions/snapshots/resets");
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");
238
239
  const block = pinnedBlock ?? await readLifecycleBlock(client);
239
240
  const context = { client, account: planned.account, orchestrator: planned.plan.orchestrator, block, chainId: planned.chainId };
240
241
  const { limits, configuration } = await resolveLifecycleLimits(context, options.limits === undefined ? planned.limits : options.limits);
241
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 };
242
243
  if (rpcQuantity(await lifecycleRpc(client, "eth_chainId"), "chain ID") !== planned.chainId) throw new LifecyclePlanningError("CHAIN_MISMATCH", "RPC chain changed before sequential simulation");
243
- const accountCode = rpcHex(await lifecycleRpc(client, "eth_getCode", [planned.account, toHex(block.number)]), "account code");
244
+ const accountCode = rpcHex(await lifecyclePinnedRpc(client, "eth_getCode", [planned.account, toHex(block.number)], block), "account code");
244
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" };
245
246
  for (const transaction of transactions) {
246
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");
@@ -254,7 +255,7 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
254
255
  if (limits.protocol === "nitro") {
255
256
  try {
256
257
  await proveNitroBackend(client, planned, block, limits, gasPrice);
257
- for (const transaction of transactions) posterGas.push(buffer((await readNitroPosterGas(transaction, context)).posterGas));
258
+ posterGas.push(...await Promise.all(transactions.map(async (transaction) => buffer((await readNitroPosterGas(transaction, context)).posterGas))));
258
259
  } catch (failure) {
259
260
  await assertLifecycleBlock(client, block, planned.chainId);
260
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.` };
@@ -368,13 +369,14 @@ export async function simulateLaunchTransactions(options: SimulateLaunchPlanOpti
368
369
  }
369
370
  }
370
371
  const estimates: LifecycleSimulationStep[] = [];
371
- const balance = rpcQuantity(await lifecycleRpc(client, "eth_getBalance", [planned.account, toHex(block.number)]), "creator native balance");
372
+ const balance = rpcQuantity(await lifecyclePinnedRpc(client, "eth_getBalance", [planned.account, toHex(block.number)], block), "creator native balance");
372
373
  let remainingBalance = balance;
373
374
  let affordabilityFailure: string | undefined;
375
+ const feeContext = configuration.estimateDataFee === undefined || sourceClient === client ? context : { ...context, client: sourceClient };
374
376
  for (let index = 0; index < verifiedSteps.length; index += 1) {
375
377
  const step = verifiedSteps[index]; const transaction = transactions[index]; const gasLimit = admittedGasLimits[index];
376
378
  if (step === undefined || transaction === undefined || gasLimit === undefined) throw new LifecyclePlanningError("INVALID_SIMULATION", "Missing verified fee context");
377
- const dataFee = limits.protocol === "nitro" ? 0n : configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit }, context);
379
+ const dataFee = limits.protocol === "nitro" ? 0n : configuration.estimateDataFee === undefined ? undefined : await configuration.estimateDataFee({ ...transaction, gas: gasLimit }, feeContext);
378
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");
379
381
  // Nitro gas includes poster charges. Never add the quoted poster fee again.
380
382
  const executionFee = gasLimit * gasPrice;
@@ -7,6 +7,8 @@ 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 };