@black-market/sdk 0.7.1 → 0.8.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 +58 -22
- package/dist/launch.d.ts +31 -5
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +71 -11
- package/dist/launch.js.map +1 -1
- package/dist/lifecycle/abi.d.ts +18 -0
- package/dist/lifecycle/abi.d.ts.map +1 -1
- package/dist/lifecycle/abi.js +4 -0
- package/dist/lifecycle/abi.js.map +1 -1
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/index.js.map +1 -1
- package/dist/lifecycle/planner.d.ts +1 -1
- package/dist/lifecycle/planner.d.ts.map +1 -1
- package/dist/lifecycle/planner.js +58 -10
- package/dist/lifecycle/planner.js.map +1 -1
- package/dist/lifecycle/progress.js +35 -35
- package/dist/lifecycle/progress.js.map +1 -1
- package/dist/lifecycle/rpc.d.ts +10 -4
- package/dist/lifecycle/rpc.d.ts.map +1 -1
- package/dist/lifecycle/rpc.js +137 -16
- package/dist/lifecycle/rpc.js.map +1 -1
- package/dist/lifecycle/types.d.ts +20 -1
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lifecycle/types.js.map +1 -1
- package/docs/launch-lifecycle.md +396 -64
- package/examples/.env.example +22 -2
- package/examples/README.md +315 -87
- package/examples/{launch-erc20-abyss.mjs → launch-erc20-abyss.ts} +1 -1
- package/examples/{launch-erc20-burn-mixed.mjs → launch-erc20-burn-mixed.ts} +1 -1
- package/examples/launch-erc20-depth-v4.ts +7 -0
- package/examples/{launch-erc20-dividends-abyss.mjs → launch-erc20-dividends-abyss.ts} +1 -1
- package/examples/launch-erc20-dividends-multiquote-mixed.ts +8 -0
- package/examples/launch-erc20-multi-quote.ts +8 -0
- package/examples/{launch-erc20-staking-v4.mjs → launch-erc20-staking-v4.ts} +1 -1
- package/examples/{launch-erc20-v4.mjs → launch-erc20-v4.ts} +1 -1
- package/examples/{launch-erc404-abyss.mjs → launch-erc404-abyss.ts} +1 -1
- package/examples/{launch-erc404-dividends-mixed.mjs → launch-erc404-dividends-mixed.ts} +1 -1
- package/examples/{launch-erc404-v4.mjs → launch-erc404-v4.ts} +1 -1
- package/examples/launch-example-kyber.ts +204 -0
- package/examples/launch-example-support.ts +876 -0
- package/examples/launch-example.ts +1163 -0
- package/examples/{launch-lifecycle-smoke.mjs → launch-lifecycle-smoke.ts} +1 -1
- package/examples/{launch.mjs → launch.ts} +1 -1
- package/package.json +2 -2
- package/src/launch.ts +81 -14
- package/src/lifecycle/abi.ts +4 -0
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/planner.ts +61 -12
- package/src/lifecycle/progress.ts +35 -35
- package/src/lifecycle/rpc.ts +140 -17
- package/src/lifecycle/types.ts +19 -1
- package/examples/launch-example-support.mjs +0 -393
- package/examples/launch-example.mjs +0 -633
- /package/examples/{generate-lifecycle-commitment-fixture.mjs → generate-lifecycle-commitment-fixture.ts} +0 -0
- /package/examples/{quickstart.mjs → quickstart.ts} +0 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { createPublicClient, custom, isAddress, keccak256, toHex } from "viem";
|
|
5
|
+
import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts";
|
|
6
|
+
import { createDecipheriv, createHash, scrypt, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const USD_UNIT = 10n ** 18n;
|
|
10
|
+
const Q96 = 1n << 96n;
|
|
11
|
+
const REVIEWED_API_ANCHOR_AGGREGATOR = "0x78F3556b67E17Df817D51Ef5a990cDaF09E8d3A9";
|
|
12
|
+
const REVIEWED_WETH = "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73";
|
|
13
|
+
|
|
14
|
+
/** Exact USD decimal input; never a floating-point price or a manual ETH/USD fallback. */
|
|
15
|
+
export function targetMarketCapUsdX18(value = "5000") {
|
|
16
|
+
if (!/^(?:0|[1-9]\d*)(?:\.\d{1,18})?$/.test(value)) throw exampleError("INVALID_CONFIGURATION", "EXAMPLE_TARGET_MARKET_CAP_USD must be an exact positive USD decimal with at most 18 fractional digits");
|
|
17
|
+
const [whole, fraction = ""] = value.split(".");
|
|
18
|
+
const amount = BigInt(whole) * USD_UNIT + BigInt(fraction.padEnd(18, "0"));
|
|
19
|
+
if (amount <= 0n || amount >= 1n << 256n) throw exampleError("INVALID_CONFIGURATION", "Target valuation must fit a positive uint256 USD x18 amount");
|
|
20
|
+
return amount;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function apiUint256(value, label, positive = false) {
|
|
24
|
+
if (typeof value !== "string" || !/^(?:0|[1-9]\d{0,77})$/.test(value)) throw exampleError("INVALID_PRICE_QUOTE", `${label} must be a canonical uint256 decimal string`);
|
|
25
|
+
const parsed = BigInt(value);
|
|
26
|
+
if (parsed >= 1n << 256n || positive && parsed === 0n) throw exampleError("INVALID_PRICE_QUOTE", `${label} must fit ${positive ? "a positive" : "an unsigned"} uint256`);
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** API publication supplies indicative seeding economics, not execution authority. */
|
|
31
|
+
export function validateApiWethUsdSeed({ snapshot, apiUrl, nowSeconds = BigInt(Math.floor(Date.now() / 1000)) }) {
|
|
32
|
+
const apiOrigin = new URL(launchApiUrl(apiUrl)).origin;
|
|
33
|
+
if (!snapshot || snapshot.chainId !== 4663 || !Array.isArray(snapshot.items) || snapshot.items.length > 100) {
|
|
34
|
+
throw exampleError("INVALID_PRICE_QUOTE", "Token-prices snapshot must contain a bounded chain-4663 items array");
|
|
35
|
+
}
|
|
36
|
+
const matches = snapshot.items.filter((row) => typeof row?.address === "string" && row.address.toLowerCase() === REVIEWED_WETH.toLowerCase());
|
|
37
|
+
if (matches.length !== 1) throw exampleError("INVALID_PRICE_QUOTE", "Token-prices snapshot must contain exactly one reviewed WETH row");
|
|
38
|
+
const row = matches[0];
|
|
39
|
+
if (row.chainId !== 4663 || row.decimals !== 18 || row.metadataStatus !== "ready" || row.priced !== true) {
|
|
40
|
+
throw exampleError("INVALID_PRICE_QUOTE", "WETH USD seed requires chain 4663, ready metadata, 18 decimals and an API-published price");
|
|
41
|
+
}
|
|
42
|
+
const provenance = row.provenance;
|
|
43
|
+
if (row.source !== "anchor" || typeof provenance?.anchorToken !== "string" || provenance.anchorToken.toLowerCase() !== REVIEWED_WETH.toLowerCase() ||
|
|
44
|
+
provenance?.anchor?.kind !== "chainlink" || typeof provenance.anchor.aggregator !== "string" || provenance.anchor.aggregator.toLowerCase() !== REVIEWED_API_ANCHOR_AGGREGATOR.toLowerCase() ||
|
|
45
|
+
provenance.hopCount !== 0 || !Array.isArray(provenance.path) || provenance.path.length !== 0) {
|
|
46
|
+
throw exampleError("PRICE_SOURCE_CHANGED", "WETH USD seed must retain its reviewed direct Chainlink anchor; obtain a new source review");
|
|
47
|
+
}
|
|
48
|
+
const sourceTimestamp = apiUint256(provenance.sourceTimestamp, "Upstream source timestamp", true);
|
|
49
|
+
if (typeof nowSeconds !== "bigint" || nowSeconds < sourceTimestamp) throw exampleError("INVALID_PRICE_QUOTE", "Upstream source timestamp cannot be in the future");
|
|
50
|
+
return {
|
|
51
|
+
source: "abyss-api", apiOrigin, chainId: 4663, tokenAddress: REVIEWED_WETH, tokenDecimals: 18,
|
|
52
|
+
priceUsdX18: apiUint256(row.priceUsdX18, "WETH USD x18 price", true),
|
|
53
|
+
anchorToken: REVIEWED_WETH, anchorKind: "chainlink", anchorAggregator: REVIEWED_API_ANCHOR_AGGREGATOR, priceSource: "anchor",
|
|
54
|
+
indexedBlock: apiUint256(snapshot.indexedBlock, "API indexed block"),
|
|
55
|
+
blockNumber: apiUint256(snapshot.blockNumber, "API graph block"),
|
|
56
|
+
graphBlockNumber: apiUint256(provenance.graphBlockNumber, "WETH graph block"),
|
|
57
|
+
sourceTimestamp, retrievedAt: nowSeconds, upstreamAgeSeconds: nowSeconds - sourceTimestamp,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One bounded GET only: no redirects, pagination, retry or fallback. */
|
|
62
|
+
export async function readApiWethUsdSeed({ sdk, fixture, signal, fetchImpl = fetch }) {
|
|
63
|
+
if (fixture.chainId !== "4663" || fixture.quoteDecimals !== 18 ||
|
|
64
|
+
sdk.getAddresses(4663).weth?.toLowerCase() !== REVIEWED_WETH.toLowerCase() || fixture.quoteAsset?.toLowerCase() !== REVIEWED_WETH.toLowerCase()) {
|
|
65
|
+
throw exampleError("PRICE_SOURCE_CHANGED", "The reviewed chain-4663 WETH seed asset changed; obtain a new source review");
|
|
66
|
+
}
|
|
67
|
+
const apiUrl = launchApiUrl(fixture.apiUrl);
|
|
68
|
+
const url = new URL("/api/v1/token-prices", apiUrl);
|
|
69
|
+
url.searchParams.set("chainId", "4663"); url.searchParams.set("limit", "100");
|
|
70
|
+
const requestSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
|
|
71
|
+
const maxBytes = 1024 * 1024;
|
|
72
|
+
try {
|
|
73
|
+
requestSignal.throwIfAborted();
|
|
74
|
+
const response = await fetchImpl(url, { method: "GET", redirect: "error", cache: "no-store", headers: { Accept: "application/json" }, signal: requestSignal });
|
|
75
|
+
if (!response.ok || response.redirected || response.url && new URL(response.url).href !== url.href) throw exampleError("PRICE_API_UNAVAILABLE", "Token-prices request failed or changed its endpoint", { status: response.status });
|
|
76
|
+
if (!response.body) throw exampleError("INVALID_PRICE_QUOTE", "Token-prices response body is missing");
|
|
77
|
+
const reader = response.body.getReader();
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let size = 0;
|
|
80
|
+
try {
|
|
81
|
+
const length = response.headers.get("content-length");
|
|
82
|
+
if (length !== null && (!/^\d{1,20}$/.test(length) || BigInt(length) > BigInt(maxBytes))) throw exampleError("INVALID_PRICE_QUOTE", "Token-prices response exceeds its 1 MiB body limit");
|
|
83
|
+
while (true) {
|
|
84
|
+
requestSignal.throwIfAborted();
|
|
85
|
+
const { done, value } = await reader.read();
|
|
86
|
+
requestSignal.throwIfAborted();
|
|
87
|
+
if (done) break;
|
|
88
|
+
size += value.byteLength;
|
|
89
|
+
if (size > maxBytes) throw exampleError("INVALID_PRICE_QUOTE", "Token-prices response exceeds its 1 MiB body limit");
|
|
90
|
+
chunks.push(value);
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
await reader.cancel().catch(() => {});
|
|
94
|
+
throw error;
|
|
95
|
+
} finally { reader.releaseLock(); }
|
|
96
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, size));
|
|
97
|
+
let snapshot;
|
|
98
|
+
try { snapshot = JSON.parse(text); }
|
|
99
|
+
catch { throw exampleError("INVALID_PRICE_QUOTE", "Token-prices response is not valid JSON"); }
|
|
100
|
+
return validateApiWethUsdSeed({ snapshot, apiUrl });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.name === "LaunchExampleError") throw error;
|
|
103
|
+
throw exampleError("PRICE_API_UNAVAILABLE", "Token-prices snapshot could not be retrieved or decoded", { cause: error });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function resolveExampleQuotes({ sdk, fixture, scenario }) {
|
|
107
|
+
const quotes = [...new Set(scenario.markets.map((market) => market.quote ?? "WETH"))].map((symbol) => {
|
|
108
|
+
if (symbol === "WETH") return { symbol, tokenAddress: fixture.quoteAsset, tokenDecimals: fixture.quoteDecimals };
|
|
109
|
+
const catalog = sdk.AUCTION_QUOTE_OPTIONS?.find((quote) => quote.id === "USDG");
|
|
110
|
+
if (symbol !== "USDG" || !catalog || catalog.address.toLowerCase() !== sdk.ROBINHOOD_USDG?.toLowerCase() ||
|
|
111
|
+
catalog.decimals !== 6 || catalog.usdPeg !== true) {
|
|
112
|
+
throw exampleError("UNSUPPORTED_QUOTE", "USDG requires the SDK's reviewed chain-4663 six-decimal USD-peg asset");
|
|
113
|
+
}
|
|
114
|
+
return { symbol, tokenAddress: catalog.address, tokenDecimals: catalog.decimals };
|
|
115
|
+
});
|
|
116
|
+
return quotes.sort((left, right) => BigInt(left.tokenAddress) < BigInt(right.tokenAddress) ? -1 : 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function readExampleQuoteUsdSeed({ sdk, fixture, quote, signal, fetchImpl = fetch }) {
|
|
120
|
+
signal?.throwIfAborted();
|
|
121
|
+
if (quote.symbol === "WETH") return readApiWethUsdSeed({
|
|
122
|
+
sdk, fixture: { ...fixture, quoteAsset: quote.tokenAddress, quoteDecimals: quote.tokenDecimals }, signal, fetchImpl,
|
|
123
|
+
});
|
|
124
|
+
const catalog = sdk.AUCTION_QUOTE_OPTIONS?.find((row) => row.id === "USDG");
|
|
125
|
+
if (fixture.chainId !== "4663" || quote.symbol !== "USDG" || !catalog || catalog.usdPeg !== true ||
|
|
126
|
+
catalog.decimals !== 6 || quote.tokenDecimals !== 6 ||
|
|
127
|
+
catalog.address.toLowerCase() !== sdk.ROBINHOOD_USDG?.toLowerCase() ||
|
|
128
|
+
quote.tokenAddress.toLowerCase() !== catalog.address.toLowerCase()) {
|
|
129
|
+
throw exampleError("PRICE_SOURCE_CHANGED", "The reviewed chain-4663 USDG peg asset changed; obtain a new explicit review");
|
|
130
|
+
}
|
|
131
|
+
return { source: "usd-peg", priceSource: "usd-peg", chainId: 4663,
|
|
132
|
+
tokenAddress: quote.tokenAddress, tokenDecimals: 6, priceUsdX18: USD_UNIT,
|
|
133
|
+
retrievedAt: BigInt(Math.floor(Date.now() / 1000)) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Each quote's protected conversion output is split across its ordered buys exactly. */
|
|
137
|
+
export function buildExampleBuysAndFunding({ sdk, fixture, scenario, markets, quotes, predictedToken }) {
|
|
138
|
+
const allocations = new Map(quotes.map((quote) => {
|
|
139
|
+
if (!quote.conversion) throw exampleError("MISSING_CONVERSION", "Every quote requires actual reviewed funding evidence; no funding fallback");
|
|
140
|
+
const count = markets.filter((market) => market.quoteAsset.toLowerCase() === quote.tokenAddress.toLowerCase()).length * scenario.buysPerMarket;
|
|
141
|
+
// Native-wrap (WETH) buys consume the exact per-buy ETH input; Kyber-funded
|
|
142
|
+
// quotes consume the API's protected minimum output, split exactly.
|
|
143
|
+
const total = quote.conversion.nativeWrap === true
|
|
144
|
+
? quote.conversion.nativeInputPerBuy * BigInt(count)
|
|
145
|
+
: BigInt(quote.conversion.response.amountOutMinimum);
|
|
146
|
+
const amounts = allocateMarketBudgets(total, count);
|
|
147
|
+
return [quote.tokenAddress.toLowerCase(), { amounts, next: 0 }];
|
|
148
|
+
}));
|
|
149
|
+
const buys = markets.flatMap((market, marketIndex) => {
|
|
150
|
+
const allocation = allocations.get(market.quoteAsset.toLowerCase());
|
|
151
|
+
if (!allocation) throw exampleError("UNSUPPORTED_QUOTE", "Every market must have a reviewed funding output");
|
|
152
|
+
const token0 = BigInt(predictedToken) < BigInt(market.quoteAsset);
|
|
153
|
+
return Array.from({ length: scenario.buysPerMarket }, () => ({
|
|
154
|
+
marketIndex, quoteAmountIn: allocation.amounts[allocation.next++], minTokenOut: 1n, recipient: fixture.creator,
|
|
155
|
+
sqrtPriceLimitX96: token0 ? 1461446703485210103287273052203988822378723970341n : 4295128740n,
|
|
156
|
+
}));
|
|
157
|
+
});
|
|
158
|
+
const funding = quotes.map((quote) => {
|
|
159
|
+
if (quote.conversion.nativeWrap === true) {
|
|
160
|
+
// Payable native-wrap escrow funding: the launch transaction carries this
|
|
161
|
+
// ETH as msg.value and the escrow wraps it to WETH during activation.
|
|
162
|
+
const amount = quote.conversion.nativeInputPerBuy * BigInt(quote.conversion.buyCount);
|
|
163
|
+
return { asset: quote.tokenAddress, amount, kind: sdk.LifecycleFundingKind.NativeWrap,
|
|
164
|
+
inputAsset: quote.tokenAddress, inputAmount: amount, target: "0x0000000000000000000000000000000000000000", data: "0x" };
|
|
165
|
+
}
|
|
166
|
+
const response = quote.conversion.response;
|
|
167
|
+
return { asset: quote.tokenAddress, amount: BigInt(response.amountOutMinimum), kind: sdk.LifecycleFundingKind.Swap,
|
|
168
|
+
inputAsset: "0x0000000000000000000000000000000000000000", inputAmount: BigInt(response.amountIn),
|
|
169
|
+
target: response.transaction.to, data: response.transaction.data };
|
|
170
|
+
});
|
|
171
|
+
return { buys, funding };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function assertFutureLaunchDeadline(deadline, nowMs = Date.now()) {
|
|
175
|
+
if (deadline * 1000n <= BigInt(nowMs)) throw exampleError("LAUNCH_DEADLINE_EXPIRED",
|
|
176
|
+
"The reviewed launch deadline expired; obtain a new explicit economic review. Never extend or rewrite the saved plan.");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
export function assertUnchangedEconomicQuote(reviewed, current) {
|
|
181
|
+
const keys = ["source", "chainId", "tokenAddress", "tokenDecimals", "priceSource", "priceUsdX18"];
|
|
182
|
+
if (reviewed.source === "abyss-api") keys.push("apiOrigin", "anchorToken", "anchorKind", "anchorAggregator");
|
|
183
|
+
for (const key of keys) {
|
|
184
|
+
const address = ["tokenAddress", "anchorToken", "anchorAggregator"].includes(key);
|
|
185
|
+
const normalize = (value) => address ? String(value).toLowerCase() : String(value);
|
|
186
|
+
if (reviewed[key] === undefined || current[key] === undefined || normalize(reviewed[key]) !== normalize(current[key])) throw exampleError("ECONOMIC_REVIEW_CHANGED", "Quote USD seed economics changed; stop and obtain a new explicit economic review, never rewrite or replay the saved intent");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Both signature paths must pass the same final economic review guard. */
|
|
191
|
+
export function guardEconomicReviewWallet(wallet, assertEconomicAuthority) {
|
|
192
|
+
return {
|
|
193
|
+
address: wallet.address,
|
|
194
|
+
async signTypedData(typedData) { await assertEconomicAuthority(); return wallet.signTypedData(typedData); },
|
|
195
|
+
async signTransaction(wire) { await assertEconomicAuthority(); return wallet.signTransaction(wire); },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function allocateMarketBudgets(supply, count) {
|
|
200
|
+
if (supply <= 0n || !Number.isSafeInteger(count) || count < 1 || supply < BigInt(count)) throw exampleError("INVALID_ALLOCATION", "Each market requires a positive share of actual total supply");
|
|
201
|
+
const base = supply / BigInt(count);
|
|
202
|
+
return Array.from({ length: count }, (_, index) => index === count - 1 ? supply - base * BigInt(index) : base);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Rounded-up mint debit, not the cap. Matches canonical SqrtPriceMath funding. */
|
|
206
|
+
export function positionMintDebit({ liquidity, sqrtPriceLowerX96: lower, sqrtPriceUpperX96: upper }, tokenIsCurrency0) {
|
|
207
|
+
const ceil = (a, b) => (a + b - 1n) / b;
|
|
208
|
+
return tokenIsCurrency0 ? ceil(ceil((liquidity << 96n) * (upper - lower), upper), lower) : ceil(liquidity * (upper - lower), Q96);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Rational token price in raw paired units. x18 displays floor only at the end. */
|
|
212
|
+
export function openingEconomics({ sqrtPriceX96, tokenIsCurrency0, quoteDecimals, quoteUsdX18, supply }) {
|
|
213
|
+
const squared = sqrtPriceX96 * sqrtPriceX96;
|
|
214
|
+
const numerator = tokenIsCurrency0 ? squared : 1n << 192n;
|
|
215
|
+
const denominator = tokenIsCurrency0 ? 1n << 192n : squared;
|
|
216
|
+
const quoteUnit = 10n ** BigInt(quoteDecimals);
|
|
217
|
+
return {
|
|
218
|
+
rawQuotePerRawToken: { numerator, denominator },
|
|
219
|
+
unitPriceQuoteX18: numerator * USD_UNIT * USD_UNIT / (denominator * quoteUnit),
|
|
220
|
+
unitPriceUsdX18: numerator * USD_UNIT * quoteUsdX18 / (denominator * quoteUnit),
|
|
221
|
+
openingValuationUsdX18: numerator * supply * quoteUsdX18 / (denominator * quoteUnit),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function deriveExampleMarketEconomics({ sdk, supply, tokenBudget, tokenIsCurrency0, positions, venue, quoteUsdX18, targetUsdX18, quoteDecimals, distributionPreset = "staircase" }) {
|
|
226
|
+
const recipe = sdk.deriveLaunchPoolRecipe({ supply, tokenBudget, pairedTokenDecimals: quoteDecimals,
|
|
227
|
+
pairedTokenUsdPriceX18: quoteUsdX18, targetMarketCapUsdX18: targetUsdX18, launchedTokenIsQuote: tokenIsCurrency0, fee: 3000, tickSpacing: 60 });
|
|
228
|
+
const ranges = positions === 1 ? [{
|
|
229
|
+
tickLower: recipe.tickLower, tickUpper: recipe.tickUpper, liquidity: recipe.liquidity, maxTokenAmount: tokenBudget,
|
|
230
|
+
sqrtPriceLowerX96: sdk.getSqrtRatioAtTick(recipe.tickLower), sqrtPriceUpperX96: sdk.getSqrtRatioAtTick(recipe.tickUpper),
|
|
231
|
+
}] : sdk.deriveLaunchPositionRanges({ launchSqrtPriceX96: recipe.launchSqrtPriceX96, launchedTokenIsQuote: tokenIsCurrency0,
|
|
232
|
+
tokenBudget, tickSpacing: 60, venue: venue === "v4" ? "uniswap-v4" : "abyss", distributionPreset,
|
|
233
|
+
positionGaps: "adjacent", extendFinalRangeToBoundary: true, positionCount: positions }).positions;
|
|
234
|
+
const reviewedPositions = ranges.map((position) => {
|
|
235
|
+
const expectedMintDebit = positionMintDebit(position, tokenIsCurrency0);
|
|
236
|
+
if (expectedMintDebit <= 0n || expectedMintDebit > position.maxTokenAmount) throw exampleError("INVALID_ALLOCATION", "Recipe mint debit exceeds its exact allocation");
|
|
237
|
+
return { ...position, expectedMintDebit, roundingBurn: position.maxTokenAmount - expectedMintDebit };
|
|
238
|
+
});
|
|
239
|
+
return { venue, tokenBudget, distributionPreset: positions === 1 ? "single-range" : distributionPreset,
|
|
240
|
+
positionGaps: "adjacent", extendFinalRangeToBoundary: true,
|
|
241
|
+
tokenIsCurrency0, openingTick: recipe.launchTick, sqrtPriceX96: recipe.launchSqrtPriceX96, tickSpacing: 60,
|
|
242
|
+
...openingEconomics({ sqrtPriceX96: recipe.launchSqrtPriceX96, tokenIsCurrency0, quoteDecimals, quoteUsdX18, supply }),
|
|
243
|
+
positions: reviewedPositions, roundingBurn: reviewedPositions.reduce((sum, position) => sum + position.roundingBurn, 0n) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function economicReviewHash(review) {
|
|
247
|
+
return keccak256(toHex(JSON.stringify(review, (_key, value) => typeof value === "bigint" ? value.toString() : value)));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
export function exampleError(code, message, details = {}) {
|
|
252
|
+
return Object.assign(new Error(message), { name: "LaunchExampleError", code, ...details });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** An interrupted simulation still must restore its snapshot; never revert source state. */
|
|
256
|
+
export function rpcRequestSignal(signal, backend, method) {
|
|
257
|
+
const cleanup = backend === "simulation" && ["evm_revert", "anvil_stopImpersonatingAccount", "hardhat_stopImpersonatingAccount"].includes(method);
|
|
258
|
+
if (!cleanup) signal.throwIfAborted();
|
|
259
|
+
// Cold nested-fork state fetches can exceed 30 seconds. Bound each request,
|
|
260
|
+
// including instrumented controlled simulation; never retry a source broadcast.
|
|
261
|
+
const timeout = AbortSignal.timeout(120_000);
|
|
262
|
+
return cleanup ? timeout : AbortSignal.any([signal, timeout]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
export function loopbackUrl(value, label) {
|
|
267
|
+
let url;
|
|
268
|
+
try { url = new URL(value); } catch { throw exampleError("UNSAFE_ENDPOINT", `${label} must be an explicit loopback HTTP(S) URL`); }
|
|
269
|
+
if (!["http:", "https:"].includes(url.protocol) || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) || url.username || url.password || url.search || url.hash || !["", "/"].includes(url.pathname)) {
|
|
270
|
+
throw exampleError("UNSAFE_ENDPOINT", `${label} must be a loopback HTTP(S) service root without credentials, path, query or fragment`);
|
|
271
|
+
}
|
|
272
|
+
return url.href;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Register endpoint secrets before parsing/validation can emit public evidence. */
|
|
276
|
+
export function registerEndpointSecrets(redactor, value) {
|
|
277
|
+
if (typeof value !== "string") return;
|
|
278
|
+
try {
|
|
279
|
+
const url = new URL(value);
|
|
280
|
+
const values = [url.username, url.password, url.search, url.hash, ...url.searchParams.values()];
|
|
281
|
+
if (url.pathname !== "/") values.push(url.pathname, ...url.pathname.split("/").filter(Boolean));
|
|
282
|
+
for (const secret of values) {
|
|
283
|
+
redactor.addSecret(secret);
|
|
284
|
+
try { redactor.addSecret(decodeURIComponent(secret)); } catch { /* Invalid encoding remains registered verbatim. */ }
|
|
285
|
+
}
|
|
286
|
+
if (values.some(Boolean)) redactor.addSecret(value);
|
|
287
|
+
} catch { redactor.addSecret(value); }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function executionRpcUrl(value) {
|
|
291
|
+
let url;
|
|
292
|
+
try { url = new URL(value); } catch { throw exampleError("UNSAFE_ENDPOINT", "RPC_URL must be HTTPS or an owned loopback HTTP URL"); }
|
|
293
|
+
if (url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) throw exampleError("UNSAFE_ENDPOINT", "RPC_URL must be HTTPS or an owned loopback HTTP URL without a fragment");
|
|
294
|
+
return url.href;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function launchApiUrl(value) {
|
|
298
|
+
let url;
|
|
299
|
+
try { url = new URL(value); } catch { throw exampleError("UNSAFE_ENDPOINT", "LAUNCH_API_URL must be HTTPS or an owned loopback root"); }
|
|
300
|
+
if (url.protocol === "http:") return loopbackUrl(value, "Launch API");
|
|
301
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || url.search || !["", "/"].includes(url.pathname)) throw exampleError("UNSAFE_ENDPOINT", "LAUNCH_API_URL must be a service root without credentials, path, query or fragment");
|
|
302
|
+
return url.href;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Image capability/public URLs may carry a path/query, but never credentials or fragments. */
|
|
306
|
+
export function exampleImageUrl(value) {
|
|
307
|
+
let url;
|
|
308
|
+
try { url = new URL(value); } catch { throw exampleError("UNSAFE_ENDPOINT", "Image URL must be HTTPS or an owned loopback HTTP URL"); }
|
|
309
|
+
// The published image is served from the launch API's public CDN (e.g. the R2
|
|
310
|
+
// bucket host), which is a different origin than the API service root. Accept
|
|
311
|
+
// any credential-free HTTPS image URL, plus owned loopback HTTP for local runs.
|
|
312
|
+
const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
313
|
+
if (!(url.protocol === "https:" || loopback)) throw exampleError("UNSAFE_ENDPOINT", "Image URL must be HTTPS or an owned loopback HTTP URL");
|
|
314
|
+
if (url.username || url.password || url.hash) throw exampleError("UNSAFE_ENDPOINT", "Image URL must not contain credentials or a fragment");
|
|
315
|
+
return url.href;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let environmentLoaded = false;
|
|
319
|
+
export function loadExampleEnvironment() {
|
|
320
|
+
if (environmentLoaded) return;
|
|
321
|
+
environmentLoaded = true;
|
|
322
|
+
try { process.loadEnvFile(); }
|
|
323
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
324
|
+
}
|
|
325
|
+
/** Bound descriptor reads, including file growth, without following a final symlink. */
|
|
326
|
+
function readBoundedExampleFile(path, limit, label, privateFile = false) {
|
|
327
|
+
if (typeof path !== "string" || !path.trim() || path.includes("\0") || Buffer.byteLength(path) > 4096) {
|
|
328
|
+
throw exampleError("INVALID_CONFIGURATION", `${label} requires a valid local file path`);
|
|
329
|
+
}
|
|
330
|
+
let fd;
|
|
331
|
+
try {
|
|
332
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
333
|
+
const before = fstatSync(fd);
|
|
334
|
+
if (!before.isFile() || privateFile && ((before.mode & 0o077) !== 0 || process.getuid && before.uid !== process.getuid())) {
|
|
335
|
+
throw exampleError("UNSAFE_LOCAL_FILE", `${label} must be a regular file${privateFile ? " owned by the current user with owner-only permissions" : ""}`);
|
|
336
|
+
}
|
|
337
|
+
if (before.size < 1 || before.size > limit) throw exampleError("LOCAL_FILE_SIZE", `${label} must contain 1..${limit} bytes`);
|
|
338
|
+
const bytes = Buffer.alloc(before.size + 1);
|
|
339
|
+
let size = 0;
|
|
340
|
+
while (size < bytes.length) {
|
|
341
|
+
const count = readSync(fd, bytes, size, bytes.length - size, null);
|
|
342
|
+
if (count === 0) break;
|
|
343
|
+
size += count;
|
|
344
|
+
}
|
|
345
|
+
const after = fstatSync(fd);
|
|
346
|
+
if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) {
|
|
347
|
+
bytes.fill(0);
|
|
348
|
+
throw exampleError("LOCAL_FILE_CHANGED", `${label} changed while being read`);
|
|
349
|
+
}
|
|
350
|
+
return bytes.subarray(0, size);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error.name === "LaunchExampleError") throw error;
|
|
353
|
+
throw exampleError("LOCAL_FILE_UNAVAILABLE", `${label} could not be read`);
|
|
354
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Minimal authenticated V3 scrypt/AES-128-CTR decryption using Node + existing viem. */
|
|
358
|
+
export async function loadExampleKeystore({ path, password = "", redactor = new Redactor() }) {
|
|
359
|
+
redactor.addSecret(path); redactor.addSecret(password);
|
|
360
|
+
if (typeof password !== "string" || !password.isWellFormed() || Buffer.byteLength(password) > 4096) {
|
|
361
|
+
throw exampleError("INVALID_CONFIGURATION", "Keystore password must be valid UTF-8 within 4096 bytes");
|
|
362
|
+
}
|
|
363
|
+
const bytes = readBoundedExampleFile(path, 64 * 1024, "Keystore", true);
|
|
364
|
+
let derived, passwordBytes, plaintext, macInput, computedMac;
|
|
365
|
+
try {
|
|
366
|
+
let json;
|
|
367
|
+
try { json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); }
|
|
368
|
+
catch { throw exampleError("KEYSTORE_INVALID_FORMAT", "Keystore must be valid UTF-8 V3 JSON"); }
|
|
369
|
+
const crypto = json?.crypto ?? json?.Crypto;
|
|
370
|
+
if (json?.version !== 3 || crypto?.cipher !== "aes-128-ctr" || crypto?.kdf !== "scrypt") {
|
|
371
|
+
throw exampleError("KEYSTORE_UNSUPPORTED_FORMAT", "Keystore must use V3 scrypt and AES-128-CTR");
|
|
372
|
+
}
|
|
373
|
+
const hex = (value, length) => typeof value === "string" && value.length === length * 2 && /^[\da-f]+$/i.test(value);
|
|
374
|
+
const params = crypto.kdfparams;
|
|
375
|
+
if (!hex(crypto.ciphertext, 32) || !hex(crypto.mac, 32) || !hex(crypto.cipherparams?.iv, 16) ||
|
|
376
|
+
!params || params.dklen !== 32 || typeof params.salt !== "string" || !/^(?:[\da-f]{2}){1,64}$/i.test(params.salt) ||
|
|
377
|
+
![params.n, params.r, params.p].every((value) => Number.isSafeInteger(value) && value > 0) ||
|
|
378
|
+
params.n < 2 || !Number.isInteger(Math.log2(params.n)) ||
|
|
379
|
+
json.address !== undefined && !hex(json.address, 20)) {
|
|
380
|
+
throw exampleError("KEYSTORE_INVALID_FORMAT", "Keystore contains invalid V3 cryptographic fields");
|
|
381
|
+
}
|
|
382
|
+
const maxmem = 320 * 1024 * 1024;
|
|
383
|
+
if (params.n > 2 ** 18 || params.r > 32 || params.p > 16 || params.n * params.r * params.p > 2 ** 21 ||
|
|
384
|
+
128 * params.n * params.r + 128 * params.r * params.p + 256 * params.r > maxmem ||
|
|
385
|
+
Math.log2(params.n) >= 16 * params.r) {
|
|
386
|
+
throw exampleError("KEYSTORE_KDF_LIMIT", "Keystore scrypt exceeds the supported resource limits");
|
|
387
|
+
}
|
|
388
|
+
passwordBytes = Buffer.from(password, "utf8");
|
|
389
|
+
derived = await promisify(scrypt)(passwordBytes, Buffer.from(params.salt, "hex"), 32, { N: params.n, r: params.r, p: params.p, maxmem });
|
|
390
|
+
const ciphertext = Buffer.from(crypto.ciphertext, "hex");
|
|
391
|
+
macInput = Buffer.concat([derived.subarray(16, 32), ciphertext]);
|
|
392
|
+
computedMac = Buffer.from(keccak256(macInput).slice(2), "hex");
|
|
393
|
+
if (!timingSafeEqual(computedMac, Buffer.from(crypto.mac, "hex"))) {
|
|
394
|
+
throw exampleError("KEYSTORE_AUTH_FAILED", "Keystore authentication failed");
|
|
395
|
+
}
|
|
396
|
+
const decipher = createDecipheriv("aes-128-ctr", derived.subarray(0, 16), Buffer.from(crypto.cipherparams.iv, "hex"));
|
|
397
|
+
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
398
|
+
const privateKey = toHex(plaintext);
|
|
399
|
+
redactor.addSecret(privateKey); redactor.addSecret(privateKey.slice(2));
|
|
400
|
+
let address;
|
|
401
|
+
try { address = privateKeyToAddress(privateKey); }
|
|
402
|
+
catch { throw exampleError("KEYSTORE_INVALID_KEY", "Authenticated keystore contains an invalid private key"); }
|
|
403
|
+
if (json.address !== undefined && address.toLowerCase() !== `0x${json.address}`.toLowerCase()) {
|
|
404
|
+
throw exampleError("KEYSTORE_ADDRESS_MISMATCH", "Keystore address does not match the decrypted key");
|
|
405
|
+
}
|
|
406
|
+
return privateKey;
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error.name === "LaunchExampleError") throw error;
|
|
409
|
+
throw exampleError("KEYSTORE_CRYPTO_FAILED", "Keystore cryptographic operation could not be completed");
|
|
410
|
+
} finally { for (const buffer of [bytes, derived, passwordBytes, plaintext, macInput, computedMac]) buffer?.fill(0); }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function resolveExampleIdentity(scenario, env = process.env) {
|
|
414
|
+
const text = (key, fallback, maximum, byteMaximum, required) => {
|
|
415
|
+
const raw = env[key] ?? fallback;
|
|
416
|
+
if (typeof raw !== "string" || !raw.isWellFormed()) throw exampleError("INVALID_CONFIGURATION", `${key} must be valid Unicode text`);
|
|
417
|
+
const value = raw.normalize("NFC").trim();
|
|
418
|
+
if (required && !value || [...value].length > maximum || byteMaximum !== undefined && Buffer.byteLength(value) > byteMaximum) {
|
|
419
|
+
throw exampleError("INVALID_CONFIGURATION", `${key} exceeds the token/API metadata text bounds`);
|
|
420
|
+
}
|
|
421
|
+
return value;
|
|
422
|
+
};
|
|
423
|
+
return {
|
|
424
|
+
name: text("EXAMPLE_TOKEN_NAME", `Example ${scenario.id}`, 100, 128, true),
|
|
425
|
+
symbol: text("EXAMPLE_TOKEN_SYMBOL", "EXAMPLE", 32, 32, true),
|
|
426
|
+
description: text("EXAMPLE_TOKEN_DESCRIPTION", `Actual Node SDK launch example: ${scenario.id}`, 2000, undefined, false),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function readExampleImage({ sdk, path }) {
|
|
431
|
+
if (!path) return undefined;
|
|
432
|
+
const bytes = readBoundedExampleFile(path, sdk.MAX_LAUNCH_IMAGE_BYTES, "Launch image");
|
|
433
|
+
const contentType = bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")) ? "image/png"
|
|
434
|
+
: bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff ? "image/jpeg"
|
|
435
|
+
: bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP" ? "image/webp" : undefined;
|
|
436
|
+
if (!contentType || !sdk.LAUNCH_IMAGE_CONTENT_TYPES.includes(contentType)) {
|
|
437
|
+
throw exampleError("IMAGE_INVALID", "Launch image bytes must identify an API-supported PNG, JPEG or WebP image");
|
|
438
|
+
}
|
|
439
|
+
return { bytes, descriptor: { sha256: `0x${createHash("sha256").update(bytes).digest("hex")}`, contentType, contentLength: bytes.length } };
|
|
440
|
+
}
|
|
441
|
+
export async function readExamplePublishedImage({ url, descriptor, artifacts, signal, timeoutMs = 30_000,
|
|
442
|
+
maxBytes = 5 * 1024 * 1024, fetchImpl = fetch }) {
|
|
443
|
+
const endpoint = exampleImageUrl(url);
|
|
444
|
+
const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)].filter(Boolean));
|
|
445
|
+
const evidence = { url: endpoint, expected: descriptor, status: "requesting" };
|
|
446
|
+
artifacts.event("published-image-request", evidence);
|
|
447
|
+
try {
|
|
448
|
+
const response = await fetchImpl(endpoint, { method: "GET", redirect: "error", signal: requestSignal });
|
|
449
|
+
evidence.httpStatus = response.status;
|
|
450
|
+
evidence.contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() ?? "";
|
|
451
|
+
if (response.status !== 200) throw exampleError("PUBLISHED_IMAGE_HTTP_ERROR", `Published image GET must return HTTP 200, received ${response.status}`);
|
|
452
|
+
if (!/^image\/[a-z0-9][a-z0-9.+-]*$/.test(evidence.contentType)) throw exampleError("PUBLISHED_IMAGE_TYPE_MISMATCH", "Published image must have the signed image content-type family");
|
|
453
|
+
const length = response.headers.get("content-length");
|
|
454
|
+
if (length !== null && (!/^\d{1,20}$/.test(length) || BigInt(length) > BigInt(maxBytes))) {
|
|
455
|
+
throw exampleError("PUBLISHED_IMAGE_SIZE", "Published image exceeds its bounded body limit");
|
|
456
|
+
}
|
|
457
|
+
if (!response.body) throw exampleError("PUBLISHED_IMAGE_EMPTY", "Published image body is missing");
|
|
458
|
+
const reader = response.body.getReader();
|
|
459
|
+
const digest = createHash("sha256");
|
|
460
|
+
let size = 0;
|
|
461
|
+
try {
|
|
462
|
+
while (true) {
|
|
463
|
+
requestSignal.throwIfAborted();
|
|
464
|
+
const { done, value } = await reader.read();
|
|
465
|
+
if (done) break;
|
|
466
|
+
size += value.byteLength;
|
|
467
|
+
evidence.contentLength = size;
|
|
468
|
+
if (size > maxBytes) throw exampleError("PUBLISHED_IMAGE_SIZE", "Published image exceeds its bounded body limit");
|
|
469
|
+
digest.update(value);
|
|
470
|
+
}
|
|
471
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
472
|
+
requestSignal.throwIfAborted();
|
|
473
|
+
if (size === 0) throw exampleError("PUBLISHED_IMAGE_EMPTY", "Published image body is empty");
|
|
474
|
+
evidence.sha256 = `0x${digest.digest("hex")}`;
|
|
475
|
+
evidence.transformed = evidence.sha256 !== descriptor.sha256;
|
|
476
|
+
if (!evidence.transformed) {
|
|
477
|
+
if (evidence.contentType !== descriptor.contentType) throw exampleError("PUBLISHED_IMAGE_TYPE_MISMATCH", "Byte-identical published image has a different content type");
|
|
478
|
+
if (size !== descriptor.contentLength) throw exampleError("PUBLISHED_IMAGE_SIZE", "Byte-identical published image has a different length");
|
|
479
|
+
evidence.verification = "byte-identical-upload";
|
|
480
|
+
} else {
|
|
481
|
+
evidence.verification = "nonempty-image-family";
|
|
482
|
+
evidence.note = "[INFERENCE] API/CDN image transformation: served bytes differ from the signed upload; image family and nonempty bounded body verified, not byte-for-byte upload identity";
|
|
483
|
+
}
|
|
484
|
+
evidence.status = "passed";
|
|
485
|
+
artifacts.save("published-image.json", evidence);
|
|
486
|
+
artifacts.event("published-image-verified", evidence);
|
|
487
|
+
return evidence;
|
|
488
|
+
} catch (error) {
|
|
489
|
+
evidence.status = "failed";
|
|
490
|
+
evidence.error = errorEvidence(error);
|
|
491
|
+
artifacts.save("published-image.json", evidence);
|
|
492
|
+
artifacts.event("published-image-failed", evidence);
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
export async function readExampleConfiguration(sdk, redactor, env = process.env) {
|
|
500
|
+
for (const value of [env.PRIVATE_KEY, env.EXAMPLE_KEYSTORE_PASSWORD, env.EXAMPLE_KEYSTORE_PATH, env.EXAMPLE_IMAGE_PATH]) redactor.addSecret(value);
|
|
501
|
+
if (typeof env.PRIVATE_KEY === "string") redactor.addSecret(env.PRIVATE_KEY.slice(2));
|
|
502
|
+
for (const value of [env.RPC_URL, env.LAUNCH_API_URL, env.EXAMPLE_KYBER_API_URL, env.NFT_BASE_URI]) registerEndpointSecrets(redactor, value);
|
|
503
|
+
const hasKey = typeof env.PRIVATE_KEY === "string" && env.PRIVATE_KEY.trim().length > 0;
|
|
504
|
+
const hasKeystore = typeof env.EXAMPLE_KEYSTORE_PATH === "string" && env.EXAMPLE_KEYSTORE_PATH.trim().length > 0;
|
|
505
|
+
if (hasKey && hasKeystore) throw exampleError("SIGNER_SOURCE_CONFLICT", "Configure exactly one of PRIVATE_KEY or EXAMPLE_KEYSTORE_PATH, never both");
|
|
506
|
+
if (!hasKey && !hasKeystore) throw exampleError("MISSING_CONFIGURATION", "Exactly one of PRIVATE_KEY or EXAMPLE_KEYSTORE_PATH is required");
|
|
507
|
+
if (!env.LAUNCH_API_URL?.trim()) throw exampleError("MISSING_CONFIGURATION", "LAUNCH_API_URL is required in the environment or working-directory .env");
|
|
508
|
+
const privateKey = hasKey ? env.PRIVATE_KEY : await loadExampleKeystore({ path: env.EXAMPLE_KEYSTORE_PATH, password: env.EXAMPLE_KEYSTORE_PASSWORD ?? "", redactor });
|
|
509
|
+
if (!/^0x[\da-f]{64}$/i.test(privateKey)) throw exampleError("INVALID_PRIVATE_KEY", "PRIVATE_KEY must be a 32-byte hex private key");
|
|
510
|
+
let creator;
|
|
511
|
+
try { creator = privateKeyToAddress(privateKey); }
|
|
512
|
+
catch { throw exampleError("INVALID_PRIVATE_KEY", "PRIVATE_KEY must be a valid 32-byte secp256k1 hex private key"); }
|
|
513
|
+
const addresses = sdk.getAddresses(4663);
|
|
514
|
+
const rpcUrl = executionRpcUrl(env.RPC_URL || sdk.robinhoodMainnet.rpcUrls.default.http[0]);
|
|
515
|
+
const gasCap = (key) => {
|
|
516
|
+
const value = env[key];
|
|
517
|
+
if (value === undefined || value === "") return undefined;
|
|
518
|
+
if (!/^[1-9]\d*$/.test(value)) throw exampleError("INVALID_CONFIGURATION", `${key} must be a positive decimal integer`);
|
|
519
|
+
return value;
|
|
520
|
+
};
|
|
521
|
+
const integer = (key, fallback, minimum, maximum) => {
|
|
522
|
+
if (fallback === undefined && (env[key] === undefined || env[key] === "")) return undefined;
|
|
523
|
+
const value = env[key] || String(fallback);
|
|
524
|
+
if (!/^(?:0|[1-9]\d*)$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < minimum || Number(value) > maximum) throw exampleError("INVALID_CONFIGURATION", `${key} is outside its allowed integer range`);
|
|
525
|
+
return Number(value);
|
|
526
|
+
};
|
|
527
|
+
const buyAmountWei = env.EXAMPLE_BUY_AMOUNT_WEI || "10000000000000";
|
|
528
|
+
if (!/^[1-9]\d{0,77}$/.test(buyAmountWei) || BigInt(buyAmountWei) >= 1n << 256n) throw exampleError("INVALID_CONFIGURATION", "EXAMPLE_BUY_AMOUNT_WEI must be a positive canonical uint256 wei amount");
|
|
529
|
+
const fixture = {
|
|
530
|
+
chainId: "4663", rpcUrl, apiUrl: launchApiUrl(env.LAUNCH_API_URL),
|
|
531
|
+
kyberApiUrl: launchApiUrl(env.EXAMPLE_KYBER_API_URL || env.LAUNCH_API_URL),
|
|
532
|
+
orchestrator: addresses.launchOrchestrator, implementationRegistry: addresses.launchImplementationRegistry, creator,
|
|
533
|
+
quoteAsset: addresses.weth, quoteDecimals: 18, oracleFactory: addresses.abyssFactory,
|
|
534
|
+
targetMarketCapUsdX18: targetMarketCapUsdX18(env.EXAMPLE_TARGET_MARKET_CAP_USD),
|
|
535
|
+
nftBaseUri: env.NFT_BASE_URI || "",
|
|
536
|
+
buyAmountWei, signerSource: hasKey ? "private-key" : "v3-keystore",
|
|
537
|
+
identityOverrides: Object.fromEntries(["EXAMPLE_TOKEN_NAME", "EXAMPLE_TOKEN_SYMBOL", "EXAMPLE_TOKEN_DESCRIPTION"].filter((key) => env[key] !== undefined).map((key) => [key, env[key]])),
|
|
538
|
+
imagePath: env.EXAMPLE_IMAGE_PATH || undefined,
|
|
539
|
+
executionLimits: {
|
|
540
|
+
chainTransactionGasLimit: gasCap("EXAMPLE_CHAIN_GAS_CAP"),
|
|
541
|
+
rpcTransactionGasLimit: gasCap("EXAMPLE_RPC_GAS_CAP"),
|
|
542
|
+
accountTransactionGasLimit: gasCap("EXAMPLE_ACCOUNT_GAS_CAP"),
|
|
543
|
+
maxCalldataBytes: integer("EXAMPLE_MAX_CALLDATA_BYTES", undefined, 1, Number.MAX_SAFE_INTEGER),
|
|
544
|
+
headroomBps: integer("EXAMPLE_HEADROOM_BPS", 1500, 0, 10000),
|
|
545
|
+
provenance: { scope: "user-ceilings", note: "Optional local tightening restrictions, not verified provider or account limits" },
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
return { fixture, privateKey };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const envelopeFields = ["chainId", "from", "to", "data", "value", "gas", "gasPrice", "nonce", "maxFeePerGas", "maxPriorityFeePerGas", "type", "accessList"];
|
|
552
|
+
export function transactionEnvelope(transaction) {
|
|
553
|
+
return Object.fromEntries(envelopeFields.filter((field) => transaction[field] !== undefined).map((field) => [field, transaction[field]]));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function exactNumber(value, label) {
|
|
557
|
+
const result = Number(BigInt(value));
|
|
558
|
+
if (!Number.isSafeInteger(result) || result < 0) throw exampleError("INVALID_ENVELOPE", `${label} must be a nonnegative safe integer`);
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const transactionTypes = { "0x0": "legacy", "0x1": "eip2930", "0x2": "eip1559", legacy: "legacy", eip2930: "eip2930", eip1559: "eip1559" };
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Bounded local signer for exact SDK envelopes. No unlocked-account RPC, retries,
|
|
566
|
+
* gas estimation, impersonation, source mutations or endpoint defaults.
|
|
567
|
+
* Endpoint configuration belongs to the example; this helper also works
|
|
568
|
+
* against an owned local Anvil supplied by the verification owner.
|
|
569
|
+
*/
|
|
570
|
+
export function createLocalLaunchWallet({ privateKey, creator, chainId, orchestrator, client, redactor }) {
|
|
571
|
+
redactor.addSecret(privateKey);
|
|
572
|
+
if (typeof privateKey === "string") redactor.addSecret(privateKey.slice(2));
|
|
573
|
+
if (typeof privateKey !== "string" || !/^0x[\da-f]{64}$/i.test(privateKey)) throw exampleError("INVALID_PRIVATE_KEY", "PRIVATE_KEY must be a 32-byte hex private key");
|
|
574
|
+
let account;
|
|
575
|
+
try { account = privateKeyToAccount(privateKey); }
|
|
576
|
+
catch { throw exampleError("INVALID_PRIVATE_KEY", "PRIVATE_KEY is not a valid secp256k1 private key"); }
|
|
577
|
+
const equal = (a, b) => typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
|
|
578
|
+
if (!equal(account.address, creator)) throw exampleError("CREATOR_KEY_MISMATCH", "Local key must match the launch creator");
|
|
579
|
+
const id = exactNumber(chainId, "chainId");
|
|
580
|
+
const publicClient = createPublicClient({ transport: custom(client, { retryCount: 0 }) });
|
|
581
|
+
async function assertChain() {
|
|
582
|
+
if (BigInt(await client.request({ method: "eth_chainId" })) !== BigInt(id)) throw exampleError("CHAIN_MISMATCH", "Wallet RPC chain must match the launch/envelope");
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
address: account.address,
|
|
586
|
+
async signTypedData(typedData) {
|
|
587
|
+
await assertChain();
|
|
588
|
+
if (BigInt(typedData.domain?.chainId ?? 0) !== BigInt(id) || !equal(typedData.domain?.verifyingContract, orchestrator) ||
|
|
589
|
+
!equal(typedData.message?.wallet, creator) || BigInt(typedData.message?.chainId ?? 0) !== BigInt(id)) {
|
|
590
|
+
throw exampleError("TYPED_DATA_MISMATCH", "Attribution domain, chain and wallet must match the launch");
|
|
591
|
+
}
|
|
592
|
+
const signature = await account.signTypedData(typedData);
|
|
593
|
+
redactor.addSecret(signature);
|
|
594
|
+
return signature;
|
|
595
|
+
},
|
|
596
|
+
async signTransaction(envelope) {
|
|
597
|
+
await assertChain();
|
|
598
|
+
for (const field of Object.keys(envelope)) if (!envelopeFields.includes(field)) throw exampleError("INVALID_ENVELOPE", `Unsupported transaction envelope field ${field}`);
|
|
599
|
+
if (!equal(envelope.from, creator) || BigInt(envelope.chainId ?? 0) !== BigInt(id) || !isAddress(envelope.to)) throw exampleError("INVALID_ENVELOPE", "Transaction sender, chain and destination must be explicit and match the wallet");
|
|
600
|
+
if (!/^0x(?:[\da-f]{2})*$/i.test(envelope.data ?? "") || envelope.value === undefined || envelope.gas === undefined || BigInt(envelope.gas) <= 0n) throw exampleError("INVALID_ENVELOPE", "Exact calldata, value and admitted positive gas are required");
|
|
601
|
+
const request = { ...envelope, chainId: id };
|
|
602
|
+
delete request.from;
|
|
603
|
+
for (const field of ["value", "gas", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
|
|
604
|
+
if (request[field] !== undefined) {
|
|
605
|
+
request[field] = BigInt(request[field]);
|
|
606
|
+
if (request[field] < 0n) throw exampleError("INVALID_ENVELOPE", `${field} must be unsigned`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
request.nonce = envelope.nonce === undefined
|
|
610
|
+
? exactNumber(await client.request({ method: "eth_getTransactionCount", params: [creator, "pending"] }), "nonce")
|
|
611
|
+
: exactNumber(envelope.nonce, "nonce");
|
|
612
|
+
if (request.type !== undefined) {
|
|
613
|
+
request.type = transactionTypes[request.type];
|
|
614
|
+
if (!request.type) throw exampleError("INVALID_ENVELOPE", "Only legacy/EIP-2930/EIP-1559 envelopes are supported");
|
|
615
|
+
}
|
|
616
|
+
if (request.gasPrice !== undefined && (request.maxFeePerGas !== undefined || request.maxPriorityFeePerGas !== undefined)) throw exampleError("INVALID_ENVELOPE", "Do not mix legacy and EIP-1559 fee fields");
|
|
617
|
+
if (request.gasPrice === undefined && request.maxFeePerGas === undefined && request.maxPriorityFeePerGas === undefined && request.type !== "eip1559") {
|
|
618
|
+
request.gasPrice = BigInt(await client.request({ method: "eth_gasPrice" }));
|
|
619
|
+
} else if (request.gasPrice === undefined && (request.maxFeePerGas === undefined || request.maxPriorityFeePerGas === undefined)) {
|
|
620
|
+
const fees = await publicClient.estimateFeesPerGas({ type: "eip1559" });
|
|
621
|
+
request.maxFeePerGas ??= fees.maxFeePerGas;
|
|
622
|
+
request.maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas;
|
|
623
|
+
}
|
|
624
|
+
const rawTransaction = await account.signTransaction(request);
|
|
625
|
+
redactor.addSecret(rawTransaction);
|
|
626
|
+
const wire = { ...envelope, chainId: toHex(id), nonce: toHex(request.nonce) };
|
|
627
|
+
for (const field of ["value", "gas", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) if (request[field] !== undefined) wire[field] = toHex(request[field]);
|
|
628
|
+
return { wire, rawTransaction, transactionHash: keccak256(rawTransaction) };
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Persist hash + exact unsigned envelope BEFORE the single raw broadcast attempt. */
|
|
634
|
+
export async function submitSignedRecorded({ transaction, wallet, client, wait, artifacts, signal }) {
|
|
635
|
+
signal?.throwIfAborted();
|
|
636
|
+
const signed = await wallet.signTransaction(transaction.wire);
|
|
637
|
+
artifacts.privateRecovery({ ...artifacts.recovery, signedTransactions: [
|
|
638
|
+
...(artifacts.recovery?.signedTransactions ?? []),
|
|
639
|
+
{ id: transaction.id, transactionHash: signed.transactionHash, rawTransaction: signed.rawTransaction },
|
|
640
|
+
] });
|
|
641
|
+
signal?.throwIfAborted();
|
|
642
|
+
const row = artifacts.submission({ ...transaction, wire: signed.wire }, signed.transactionHash, "broadcast-attempt");
|
|
643
|
+
// The attempt is durable even if send times out, throws, or is interrupted.
|
|
644
|
+
const returnedHash = await client.request({ method: "eth_sendRawTransaction", params: [signed.rawTransaction] });
|
|
645
|
+
if (typeof returnedHash !== "string" || returnedHash.toLowerCase() !== signed.transactionHash.toLowerCase()) {
|
|
646
|
+
throw exampleError("BROADCAST_HASH_MISMATCH", "RPC returned a hash different from the locally signed transaction", { transactionHash: signed.transactionHash, returnedHash });
|
|
647
|
+
}
|
|
648
|
+
row.status = "submitted"; row.submittedAt = new Date().toISOString();
|
|
649
|
+
artifacts.saveReceipts();
|
|
650
|
+
artifacts.event("transaction-submitted", row);
|
|
651
|
+
signal?.throwIfAborted();
|
|
652
|
+
const receipt = await wait(signed.transactionHash);
|
|
653
|
+
artifacts.inclusion(row, receipt);
|
|
654
|
+
return { row, receipt };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Public evidence retains transaction calldata and revert bytes. Secrets are identified
|
|
658
|
+
// by field names, capability syntax, URLs, and values learned before any persistence.
|
|
659
|
+
export class Redactor {
|
|
660
|
+
secrets = new Set();
|
|
661
|
+
addSecret(value) { if (typeof value === "string" && value.length > 0) this.secrets.add(value); }
|
|
662
|
+
text(value) {
|
|
663
|
+
const trimmed = value.trim();
|
|
664
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
665
|
+
try { return JSON.stringify(this.value(JSON.parse(trimmed))); } catch { /* Not a serialized JSON value. */ }
|
|
666
|
+
}
|
|
667
|
+
let result = value;
|
|
668
|
+
for (const secret of this.secrets) result = result.split(secret).join("[REDACTED]");
|
|
669
|
+
result = result.replace(/https?:\/\/[^\s<>"']+/gi, (raw) => {
|
|
670
|
+
try {
|
|
671
|
+
const url = new URL(raw);
|
|
672
|
+
const credentials = Boolean(url.username || url.password);
|
|
673
|
+
const query = Boolean(url.search);
|
|
674
|
+
url.username = ""; url.password = ""; url.search = ""; url.hash = "";
|
|
675
|
+
return `${url.href}${credentials || query ? "[REDACTED_URL_SECRET]" : ""}`;
|
|
676
|
+
} catch { return "[REDACTED_URL]"; }
|
|
677
|
+
});
|
|
678
|
+
result = result.replace(/\blus_[A-Za-z0-9_-]+\b/g, "[REDACTED]");
|
|
679
|
+
result = result.replace(/\b(?:Launch-Upload-Capability|Bearer|Basic)\s+[^\s,"'}]+/gi, "[REDACTED_AUTH]");
|
|
680
|
+
return result.replace(/((?:private[_ -]?key|signature|capability|authorization|password|secret|mnemonic|seed[_ -]?phrase|api[_ -]?key)["']?\s*[=:]\s*)(?:"[^"]*"|'[^']*'|0x[\da-f]+|[^\s,"'}]+)/gi, "$1[REDACTED]");
|
|
681
|
+
}
|
|
682
|
+
value(value) {
|
|
683
|
+
const visited = new WeakSet();
|
|
684
|
+
const visit = (item, depth) => {
|
|
685
|
+
if (depth > 30) return "[DEPTH_LIMIT]";
|
|
686
|
+
if (typeof item === "bigint") return item.toString();
|
|
687
|
+
if (typeof item === "string") return this.text(item);
|
|
688
|
+
if (typeof item === "function") return undefined;
|
|
689
|
+
if (item === null || typeof item !== "object") return item;
|
|
690
|
+
if (visited.has(item)) return "[CIRCULAR]";
|
|
691
|
+
visited.add(item);
|
|
692
|
+
if (Array.isArray(item)) {
|
|
693
|
+
const result = item.map((entry) => visit(entry, depth + 1));
|
|
694
|
+
visited.delete(item);
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
const result = {};
|
|
698
|
+
const keys = item instanceof Error ? [...new Set(["name", "message", "stack", "cause", ...Object.getOwnPropertyNames(item)])] : Object.keys(item);
|
|
699
|
+
for (const key of keys) {
|
|
700
|
+
// A mode-authorization record is public provenance, not an API credential.
|
|
701
|
+
if (key === "authorization" && item[key] === "--broadcast" &&
|
|
702
|
+
/^0x[\da-fA-F]{64}$/.test(item.reviewHash) && typeof item.admittedAt === "string") result[key] = "--broadcast";
|
|
703
|
+
else if (/private.?key|secret|signature|capability|authorization|authentication|password|credential|mnemonic|seed.?phrase|(?:access|refresh|auth)[_-]?token|api[_-]?key|cookie/i.test(key)) result[key] = "[REDACTED]";
|
|
704
|
+
else if (key === "apiOrigin") {
|
|
705
|
+
try {
|
|
706
|
+
const origin = new URL(launchApiUrl(item[key])).origin;
|
|
707
|
+
result[key] = item[key] === origin ? origin : "[REDACTED_URL]";
|
|
708
|
+
} catch { result[key] = "[REDACTED_URL]"; }
|
|
709
|
+
}
|
|
710
|
+
else if (item[key] !== undefined) result[key] = visit(item[key], depth + 1);
|
|
711
|
+
}
|
|
712
|
+
visited.delete(item);
|
|
713
|
+
return result;
|
|
714
|
+
};
|
|
715
|
+
return visit(value, 0);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function errorEvidence(error) {
|
|
720
|
+
if (!(error instanceof Error)) return { name: "UnknownError", message: String(error), cause: error };
|
|
721
|
+
const evidence = { name: error.name, code: error.code, message: error.message, stack: error.stack, cause: error.cause, revertData: error.revertData, data: error.data };
|
|
722
|
+
for (const key of Object.getOwnPropertyNames(error)) if (!(key in evidence)) evidence[key] = error[key];
|
|
723
|
+
if (evidence.revertData === undefined) evidence.revertData = findRevertData(error.data ?? error.cause);
|
|
724
|
+
return evidence;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function findRevertData(value, seen = new WeakSet()) {
|
|
728
|
+
if (typeof value === "string") return /^0x(?:[\da-f]{2})+$/i.test(value) ? value : undefined;
|
|
729
|
+
if (!value || typeof value !== "object" || seen.has(value)) return undefined;
|
|
730
|
+
seen.add(value);
|
|
731
|
+
for (const key of ["revertData", "returnValue", "output", "data", "result", "cause"]) {
|
|
732
|
+
const data = findRevertData(value[key], seen);
|
|
733
|
+
if (data) return data;
|
|
734
|
+
}
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function durableWrite(path, content, mode = 0o600) {
|
|
739
|
+
const fd = openSync(path, "w", mode);
|
|
740
|
+
try { writeFileSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export class RunArtifacts {
|
|
744
|
+
constructor(output, caseId, redactor = new Redactor(), { resume = false } = {}) {
|
|
745
|
+
this.directory = resolve(output);
|
|
746
|
+
this.redactor = redactor;
|
|
747
|
+
this.transactions = [];
|
|
748
|
+
if (resume) {
|
|
749
|
+
const recoveryPath = join(this.directory, "private-recovery.json");
|
|
750
|
+
if (!existsSync(recoveryPath)) throw exampleError("MISSING_RECOVERY", "Resume requires private-recovery.json in the original recovery directory");
|
|
751
|
+
this.recovery = JSON.parse(readBoundedExampleFile(recoveryPath, 16 * 1024 * 1024, "Private launch recovery", true).toString("utf8"));
|
|
752
|
+
for (const secret of [this.recovery.capability, this.recovery.session?.capability, this.recovery.request?.authorization?.signature,
|
|
753
|
+
...(this.recovery.signedTransactions ?? []).map((row) => row.rawTransaction)]) redactor.addSecret(secret);
|
|
754
|
+
for (const value of [this.recovery.apiUrl, this.recovery.session?.upload?.url, this.recovery.plan?.token?.metadataURI]) registerEndpointSecrets(redactor, value);
|
|
755
|
+
const receiptsPath = join(this.directory, "receipts.json");
|
|
756
|
+
if (existsSync(receiptsPath)) {
|
|
757
|
+
const retained = JSON.parse(readBoundedExampleFile(receiptsPath, 16 * 1024 * 1024, "Retained launch receipts").toString("utf8"));
|
|
758
|
+
if (!Array.isArray(retained.transactions)) throw exampleError("INVALID_RECOVERY", "Retained receipt journal requires transaction rows");
|
|
759
|
+
this.transactions = retained.transactions;
|
|
760
|
+
}
|
|
761
|
+
const configurationPath = join(this.directory, "run-config.json");
|
|
762
|
+
if (existsSync(configurationPath)) {
|
|
763
|
+
this.configuration = JSON.parse(readBoundedExampleFile(configurationPath, 16 * 1024 * 1024, "Retained launch configuration").toString("utf8")).configuration;
|
|
764
|
+
}
|
|
765
|
+
caseId = this.recovery.caseId ?? caseId;
|
|
766
|
+
} else mkdirSync(this.directory, { mode: 0o700 }); // EEXIST deliberately refuses reuse.
|
|
767
|
+
this.result = {
|
|
768
|
+
schema: "black-market.launch-example-result.v1", sdk: "node", caseId: caseId ?? null,
|
|
769
|
+
status: "failed", stage: "configuration", scope: "end-to-end", execution: "not-run",
|
|
770
|
+
chain: { status: "not-run", confirmations: 1, transactions: this.transactions },
|
|
771
|
+
api: { status: "not-run" }, startedAt: new Date().toISOString(), completedAt: null,
|
|
772
|
+
};
|
|
773
|
+
this.stageStartedAt = Date.now();
|
|
774
|
+
if (resume) {
|
|
775
|
+
this.result.stage = "resume";
|
|
776
|
+
this.result.execution = "resuming";
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
initialize() {
|
|
780
|
+
this.event(this.recovery ? "resumed" : "started", { directory: this.directory });
|
|
781
|
+
if (!this.recovery) this.saveReceipts();
|
|
782
|
+
}
|
|
783
|
+
event(event, observations = {}) {
|
|
784
|
+
const row = this.redactor.value({ timestamp: new Date().toISOString(), sdk: "node", caseId: this.result.caseId, stage: this.result.stage, event, ...observations });
|
|
785
|
+
const fd = openSync(join(this.directory, "events.jsonl"), "a", 0o600);
|
|
786
|
+
try { appendFileSync(fd, `${JSON.stringify(row)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
|
|
787
|
+
}
|
|
788
|
+
stage(stage, observations = {}) {
|
|
789
|
+
this.event("stage-completed", { elapsedMs: Date.now() - this.stageStartedAt });
|
|
790
|
+
this.result.stage = stage;
|
|
791
|
+
this.stageStartedAt = Date.now();
|
|
792
|
+
this.event("stage-started", observations);
|
|
793
|
+
}
|
|
794
|
+
save(name, value) {
|
|
795
|
+
const path = join(this.directory, name);
|
|
796
|
+
durableWrite(`${path}.tmp`, `${JSON.stringify(this.redactor.value(value), null, 2)}\n`);
|
|
797
|
+
renameSync(`${path}.tmp`, path);
|
|
798
|
+
}
|
|
799
|
+
privateRecovery(value) {
|
|
800
|
+
const recovery = { ...this.recovery, ...value };
|
|
801
|
+
// Session and broadcast updates cannot replace the original finalized intent.
|
|
802
|
+
for (const key of ["plan", "mode", "confirmations", "economicReview", "economicAdmission"]) {
|
|
803
|
+
if (this.recovery?.[key] !== undefined) recovery[key] = this.recovery[key];
|
|
804
|
+
}
|
|
805
|
+
const content = `${JSON.stringify(recovery, (_key, item) => typeof item === "bigint" ? item.toString() : item, 2)}\n`;
|
|
806
|
+
const path = join(this.directory, "private-recovery.json");
|
|
807
|
+
durableWrite(`${path}.tmp`, content, 0o600);
|
|
808
|
+
renameSync(`${path}.tmp`, path);
|
|
809
|
+
// Sync both the replacement and the fresh run directory's parent entry.
|
|
810
|
+
for (const directory of [this.directory, dirname(this.directory)]) {
|
|
811
|
+
const fd = openSync(directory, "r");
|
|
812
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
813
|
+
}
|
|
814
|
+
this.recovery = JSON.parse(content);
|
|
815
|
+
}
|
|
816
|
+
submission(transaction, transactionHash, status = "submitted") {
|
|
817
|
+
const row = { id: transaction.id, kind: transaction.kind, transaction: transaction.wire, transactionHash, status, attemptedAt: new Date().toISOString(), receipt: null };
|
|
818
|
+
if (status === "submitted") row.submittedAt = row.attemptedAt;
|
|
819
|
+
this.transactions.push(row);
|
|
820
|
+
this.saveReceipts();
|
|
821
|
+
this.event(status === "broadcast-attempt" ? "transaction-broadcast-attempt" : "transaction-submitted", row);
|
|
822
|
+
return row;
|
|
823
|
+
}
|
|
824
|
+
inclusion(row, receipt) {
|
|
825
|
+
row.receipt = receipt;
|
|
826
|
+
row.status = "included"; // Status/provenance is checked only after preserving it.
|
|
827
|
+
row.includedAt = new Date().toISOString();
|
|
828
|
+
this.saveReceipts();
|
|
829
|
+
this.event("transaction-included", { id: row.id, transactionHash: row.transactionHash, receipt });
|
|
830
|
+
}
|
|
831
|
+
saveReceipts() {
|
|
832
|
+
this.save("receipts.json", { schema: "black-market.launch-example-receipts.v1", sdk: "node", caseId: this.result.caseId, transactions: this.transactions });
|
|
833
|
+
}
|
|
834
|
+
finish(status, error) {
|
|
835
|
+
this.result.status = status;
|
|
836
|
+
this.result.completedAt = new Date().toISOString();
|
|
837
|
+
if (error) this.result.error = errorEvidence(error);
|
|
838
|
+
// Write the terminal result before its journal row, so a journal I/O error cannot
|
|
839
|
+
// erase a completed transaction or the original failure diagnosis.
|
|
840
|
+
this.save("result.json", this.result);
|
|
841
|
+
this.event(status, { elapsedMs: Date.now() - this.stageStartedAt, scope: this.result.scope, error: this.result.error });
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
export async function pause(ms, signal) {
|
|
847
|
+
signal?.throwIfAborted();
|
|
848
|
+
await delay(ms, undefined, { signal });
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export async function publishUntilIndexed({ publish, isPending, timeoutMs, artifacts, signal, now = Date.now, sleep = pause }) {
|
|
852
|
+
const deadline = now() + timeoutMs;
|
|
853
|
+
let attempt = 0;
|
|
854
|
+
for (;;) {
|
|
855
|
+
signal?.throwIfAborted();
|
|
856
|
+
const remainingMs = deadline - now();
|
|
857
|
+
if (remainingMs <= 0) throw exampleError("PUBLISH_TIMEOUT", "Actual API publication did not reach indexed optimistic/final state before the deadline");
|
|
858
|
+
artifacts.event("publish-request", { attempt: ++attempt, remainingMs });
|
|
859
|
+
try {
|
|
860
|
+
const session = await publish(remainingMs);
|
|
861
|
+
if (now() >= deadline) throw exampleError("PUBLISH_TIMEOUT", "The API publication response arrived after the indexing deadline");
|
|
862
|
+
return session;
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
if (!isPending(error)) throw error; // No retry of API/RPC/schema/provenance errors.
|
|
866
|
+
artifacts.result.api.status = "awaiting-indexer";
|
|
867
|
+
artifacts.result.api.session = error.session;
|
|
868
|
+
artifacts.event("publish-pending", { session: error.session, retryAfterMs: error.retryAfterMs });
|
|
869
|
+
const retryAfterMs = Number(error.retryAfterMs);
|
|
870
|
+
if (!Number.isFinite(retryAfterMs) || retryAfterMs < 0) throw exampleError("INVALID_RETRY_AFTER", "The API returned an invalid pending publication delay");
|
|
871
|
+
const availableMs = deadline - now();
|
|
872
|
+
if (availableMs <= 0) throw exampleError("PUBLISH_TIMEOUT", "Actual API publication exhausted its indexing deadline");
|
|
873
|
+
await sleep(Math.min(Math.max(250, retryAfterMs), availableMs), signal);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|