@black-market/sdk 0.5.0 → 0.5.2
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 +26 -123
- package/assets/abyss-social.webp +0 -0
- package/docs/launch-lifecycle.md +61 -82
- package/examples/.env.example +20 -0
- package/examples/README.md +223 -0
- package/examples/generate-lifecycle-commitment-fixture.mjs +49 -0
- package/examples/launch-erc20-abyss.mjs +7 -0
- package/examples/launch-erc20-burn-mixed.mjs +7 -0
- package/examples/launch-erc20-dividends-abyss.mjs +7 -0
- package/examples/launch-erc20-staking-v4.mjs +7 -0
- package/examples/launch-erc20-v4.mjs +7 -0
- package/examples/launch-erc404-abyss.mjs +7 -0
- package/examples/launch-erc404-dividends-mixed.mjs +7 -0
- package/examples/launch-erc404-v4.mjs +7 -0
- package/examples/launch-example-support.mjs +397 -0
- package/examples/launch-example.mjs +589 -0
- package/examples/launch-lifecycle-smoke.mjs +180 -0
- package/examples/launch.mjs +38 -0
- package/examples/quickstart.mjs +37 -0
- package/package.json +4 -4
- package/assets/splash.png +0 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { runLaunchExample } from "./launch-example.mjs";
|
|
2
|
+
|
|
3
|
+
await runLaunchExample({
|
|
4
|
+
id: "erc20-abyss-basic", description: "ERC20, one canonical Abyss position, atomic opening",
|
|
5
|
+
tokenKind: 0, rewardMode: 0, mode: "atomic",
|
|
6
|
+
markets: [{ venue: "abyss", positions: 1 }], buysPerMarket: 1,
|
|
7
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { runLaunchExample } from "./launch-example.mjs";
|
|
2
|
+
|
|
3
|
+
await runLaunchExample({
|
|
4
|
+
id: "erc20-burn-mixed", description: "Burn policy, dual-fee V4 and Abyss markets for one token",
|
|
5
|
+
tokenKind: 0, rewardMode: 0, mode: "staged", burnBps: 3000,
|
|
6
|
+
markets: [{ venue: "v4", positions: 2, feeMode: 1 }, { venue: "abyss", positions: 1 }], buysPerMarket: 1,
|
|
7
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { runLaunchExample } from "./launch-example.mjs";
|
|
2
|
+
|
|
3
|
+
await runLaunchExample({
|
|
4
|
+
id: "erc20-dividends-abyss", description: "Holder dividends and three canonical Abyss positions",
|
|
5
|
+
tokenKind: 0, rewardMode: 2, mode: "staged",
|
|
6
|
+
markets: [{ venue: "abyss", positions: 3 }], buysPerMarket: 1,
|
|
7
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { runLaunchExample } from "./launch-example.mjs";
|
|
2
|
+
|
|
3
|
+
await runLaunchExample({
|
|
4
|
+
id: "erc20-staking-v4", description: "Staking rewards, two V4 positions and two ordered opening buys",
|
|
5
|
+
tokenKind: 0, rewardMode: 1, mode: "staged",
|
|
6
|
+
markets: [{ venue: "v4", positions: 2 }], buysPerMarket: 2,
|
|
7
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { runLaunchExample } from "./launch-example.mjs";
|
|
2
|
+
|
|
3
|
+
await runLaunchExample({
|
|
4
|
+
id: "erc20-v4-basic", description: "ERC20, one pool-bound V4 position, atomic opening",
|
|
5
|
+
tokenKind: 0, rewardMode: 0, mode: "atomic",
|
|
6
|
+
markets: [{ venue: "v4", positions: 1 }], buysPerMarket: 1,
|
|
7
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { runLaunchExample } from "./launch-example.mjs";
|
|
2
|
+
|
|
3
|
+
await runLaunchExample({
|
|
4
|
+
id: "erc404-abyss", description: "ERC404 NFT units and metadata, atomic canonical Abyss",
|
|
5
|
+
tokenKind: 1, rewardMode: 0, mode: "atomic",
|
|
6
|
+
markets: [{ venue: "abyss", positions: 1 }], buysPerMarket: 1,
|
|
7
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { runLaunchExample } from "./launch-example.mjs";
|
|
2
|
+
|
|
3
|
+
await runLaunchExample({
|
|
4
|
+
id: "erc404-dividends-mixed", description: "ERC404 holder dividends, both venues and five permanent positions",
|
|
5
|
+
tokenKind: 1, rewardMode: 2, mode: "staged",
|
|
6
|
+
markets: [{ venue: "v4", positions: 2 }, { venue: "abyss", positions: 3 }], buysPerMarket: 1,
|
|
7
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { runLaunchExample } from "./launch-example.mjs";
|
|
2
|
+
|
|
3
|
+
await runLaunchExample({
|
|
4
|
+
id: "erc404-v4", description: "ERC404 NFT units and metadata, staged pool-bound V4",
|
|
5
|
+
tokenKind: 1, rewardMode: 0, mode: "staged",
|
|
6
|
+
markets: [{ venue: "v4", positions: 1 }], buysPerMarket: 1,
|
|
7
|
+
});
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { 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 } from "viem/accounts";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export function exampleError(code, message, details = {}) {
|
|
9
|
+
return Object.assign(new Error(message), { name: "LaunchExampleError", code, ...details });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** An interrupted simulation still must restore its snapshot; never revert source state. */
|
|
13
|
+
export function rpcRequestSignal(signal, backend, method) {
|
|
14
|
+
const cleanup = backend === "simulation" && ["evm_revert", "anvil_stopImpersonatingAccount", "hardhat_stopImpersonatingAccount"].includes(method);
|
|
15
|
+
if (!cleanup) signal.throwIfAborted();
|
|
16
|
+
// Cold nested-fork state fetches can exceed 30 seconds. Bound each request,
|
|
17
|
+
// including instrumented controlled simulation; never retry a source broadcast.
|
|
18
|
+
const timeout = AbortSignal.timeout(120_000);
|
|
19
|
+
return cleanup ? timeout : AbortSignal.any([signal, timeout]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export function loopbackUrl(value, label) {
|
|
24
|
+
let url;
|
|
25
|
+
try { url = new URL(value); } catch { throw exampleError("UNSAFE_ENDPOINT", `${label} must be an explicit loopback HTTP(S) URL`); }
|
|
26
|
+
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)) {
|
|
27
|
+
throw exampleError("UNSAFE_ENDPOINT", `${label} must be a loopback HTTP(S) service root without credentials, path, query or fragment`);
|
|
28
|
+
}
|
|
29
|
+
return url.href;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Register endpoint secrets before parsing/validation can emit public evidence. */
|
|
33
|
+
export function registerEndpointSecrets(redactor, value) {
|
|
34
|
+
if (typeof value !== "string") return;
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(value);
|
|
37
|
+
const values = [url.username, url.password, url.search, url.hash, ...url.searchParams.values()];
|
|
38
|
+
if (url.pathname !== "/") values.push(url.pathname, ...url.pathname.split("/").filter(Boolean));
|
|
39
|
+
for (const secret of values) {
|
|
40
|
+
redactor.addSecret(secret);
|
|
41
|
+
try { redactor.addSecret(decodeURIComponent(secret)); } catch { /* Invalid encoding remains registered verbatim. */ }
|
|
42
|
+
}
|
|
43
|
+
if (values.some(Boolean)) redactor.addSecret(value);
|
|
44
|
+
} catch { redactor.addSecret(value); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function executionRpcUrl(value) {
|
|
48
|
+
let url;
|
|
49
|
+
try { url = new URL(value); } catch { throw exampleError("UNSAFE_ENDPOINT", "RPC_URL must be HTTPS or an owned loopback HTTP URL"); }
|
|
50
|
+
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");
|
|
51
|
+
return url.href;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function launchApiUrl(value) {
|
|
55
|
+
let url;
|
|
56
|
+
try { url = new URL(value); } catch { throw exampleError("UNSAFE_ENDPOINT", "LAUNCH_API_URL must be HTTPS or an owned loopback root"); }
|
|
57
|
+
if (url.protocol === "http:") return loopbackUrl(value, "Launch API");
|
|
58
|
+
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");
|
|
59
|
+
return url.href;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let environmentLoaded = false;
|
|
63
|
+
export function loadExampleEnvironment() {
|
|
64
|
+
if (environmentLoaded) return;
|
|
65
|
+
environmentLoaded = true;
|
|
66
|
+
try { process.loadEnvFile(); }
|
|
67
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readExampleConfiguration(sdk, redactor, env = process.env) {
|
|
71
|
+
redactor.addSecret(env.PRIVATE_KEY);
|
|
72
|
+
if (typeof env.PRIVATE_KEY === "string") redactor.addSecret(env.PRIVATE_KEY.slice(2));
|
|
73
|
+
for (const value of [env.RPC_URL, env.LAUNCH_API_URL, env.SIMULATION_RPC_URL, env.NFT_BASE_URI]) registerEndpointSecrets(redactor, value);
|
|
74
|
+
if (!env.PRIVATE_KEY?.trim()) throw exampleError("MISSING_CONFIGURATION", "PRIVATE_KEY is required in the environment or working-directory .env");
|
|
75
|
+
if (!env.LAUNCH_API_URL?.trim()) throw exampleError("MISSING_CONFIGURATION", "LAUNCH_API_URL is required in the environment or working-directory .env");
|
|
76
|
+
if (!/^0x[\da-f]{64}$/i.test(env.PRIVATE_KEY)) throw exampleError("INVALID_PRIVATE_KEY", "PRIVATE_KEY must be a 32-byte hex private key");
|
|
77
|
+
let account;
|
|
78
|
+
try { account = privateKeyToAccount(env.PRIVATE_KEY); }
|
|
79
|
+
catch { throw exampleError("INVALID_PRIVATE_KEY", "PRIVATE_KEY must be a valid 32-byte secp256k1 hex private key"); }
|
|
80
|
+
const addresses = sdk.getAddresses(4663);
|
|
81
|
+
const rpcUrl = executionRpcUrl(env.RPC_URL || sdk.robinhoodMainnet.rpcUrls.default.http[0]);
|
|
82
|
+
const forkRpcUrl = env.SIMULATION_RPC_URL ? loopbackUrl(env.SIMULATION_RPC_URL, "Simulation RPC") : undefined;
|
|
83
|
+
if (forkRpcUrl) {
|
|
84
|
+
const source = new URL(rpcUrl); const fork = new URL(forkRpcUrl);
|
|
85
|
+
const port = (url) => url.port || (url.protocol === "https:" ? "443" : "80");
|
|
86
|
+
if (["127.0.0.1", "localhost", "[::1]"].includes(source.hostname) && port(source) === port(fork)) throw exampleError("UNSAFE_ENDPOINT", "Execution and disposable simulation RPCs must use distinct loopback ports");
|
|
87
|
+
}
|
|
88
|
+
const gasCap = (key) => {
|
|
89
|
+
const value = env[key] || "16000000";
|
|
90
|
+
if (!/^[1-9]\d*$/.test(value)) throw exampleError("INVALID_CONFIGURATION", `${key} must be a positive decimal integer`);
|
|
91
|
+
return value;
|
|
92
|
+
};
|
|
93
|
+
const integer = (key, fallback, minimum, maximum) => {
|
|
94
|
+
const value = env[key] || String(fallback);
|
|
95
|
+
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`);
|
|
96
|
+
return Number(value);
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
chainId: "4663", rpcUrl, forkRpcUrl, apiUrl: launchApiUrl(env.LAUNCH_API_URL),
|
|
100
|
+
orchestrator: addresses.launchOrchestrator, creator: account.address,
|
|
101
|
+
quoteAsset: addresses.weth, quoteDecimals: 18, oracleFactory: addresses.abyssFactory,
|
|
102
|
+
nftBaseUri: env.NFT_BASE_URI || "",
|
|
103
|
+
executionLimits: {
|
|
104
|
+
chainTransactionGasLimit: gasCap("EXAMPLE_CHAIN_GAS_CAP"),
|
|
105
|
+
rpcTransactionGasLimit: gasCap("EXAMPLE_RPC_GAS_CAP"),
|
|
106
|
+
accountTransactionGasLimit: gasCap("EXAMPLE_ACCOUNT_GAS_CAP"),
|
|
107
|
+
maxCalldataBytes: integer("EXAMPLE_MAX_CALLDATA_BYTES", 131072, 1, Number.MAX_SAFE_INTEGER),
|
|
108
|
+
headroomBps: integer("EXAMPLE_HEADROOM_BPS", 1000, 0, 10000),
|
|
109
|
+
provenance: { scope: "example-ceilings", note: "EXAMPLE ceilings, not verified provider or account limits; bounded by each observed block gas limit" },
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const envelopeFields = ["chainId", "from", "to", "data", "value", "gas", "gasPrice", "nonce", "maxFeePerGas", "maxPriorityFeePerGas", "type", "accessList"];
|
|
115
|
+
export function transactionEnvelope(transaction) {
|
|
116
|
+
return Object.fromEntries(envelopeFields.filter((field) => transaction[field] !== undefined).map((field) => [field, transaction[field]]));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function exactNumber(value, label) {
|
|
120
|
+
const result = Number(BigInt(value));
|
|
121
|
+
if (!Number.isSafeInteger(result) || result < 0) throw exampleError("INVALID_ENVELOPE", `${label} must be a nonnegative safe integer`);
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const transactionTypes = { "0x0": "legacy", "0x1": "eip2930", "0x2": "eip1559", legacy: "legacy", eip2930: "eip2930", eip1559: "eip1559" };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Bounded local signer for exact SDK envelopes. No unlocked-account RPC, retries,
|
|
129
|
+
* gas estimation, impersonation, source mutations or endpoint defaults.
|
|
130
|
+
* Endpoint configuration belongs to the example; this helper also works
|
|
131
|
+
* against an owned local Anvil supplied by the verification owner.
|
|
132
|
+
*/
|
|
133
|
+
export function createLocalLaunchWallet({ privateKey, creator, chainId, orchestrator, client, redactor }) {
|
|
134
|
+
redactor.addSecret(privateKey);
|
|
135
|
+
if (typeof privateKey === "string") redactor.addSecret(privateKey.slice(2));
|
|
136
|
+
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");
|
|
137
|
+
let account;
|
|
138
|
+
try { account = privateKeyToAccount(privateKey); }
|
|
139
|
+
catch { throw exampleError("INVALID_PRIVATE_KEY", "PRIVATE_KEY is not a valid secp256k1 private key"); }
|
|
140
|
+
const equal = (a, b) => typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
|
|
141
|
+
if (!equal(account.address, creator)) throw exampleError("CREATOR_KEY_MISMATCH", "Local key must match the launch creator");
|
|
142
|
+
const id = exactNumber(chainId, "chainId");
|
|
143
|
+
const publicClient = createPublicClient({ transport: custom(client, { retryCount: 0 }) });
|
|
144
|
+
async function assertChain() {
|
|
145
|
+
if (BigInt(await client.request({ method: "eth_chainId" })) !== BigInt(id)) throw exampleError("CHAIN_MISMATCH", "Wallet RPC chain must match the launch/envelope");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
address: account.address,
|
|
149
|
+
async signTypedData(typedData) {
|
|
150
|
+
await assertChain();
|
|
151
|
+
if (BigInt(typedData.domain?.chainId ?? 0) !== BigInt(id) || !equal(typedData.domain?.verifyingContract, orchestrator) ||
|
|
152
|
+
!equal(typedData.message?.wallet, creator) || BigInt(typedData.message?.chainId ?? 0) !== BigInt(id)) {
|
|
153
|
+
throw exampleError("TYPED_DATA_MISMATCH", "Attribution domain, chain and wallet must match the launch");
|
|
154
|
+
}
|
|
155
|
+
const signature = await account.signTypedData(typedData);
|
|
156
|
+
redactor.addSecret(signature);
|
|
157
|
+
return signature;
|
|
158
|
+
},
|
|
159
|
+
async signTransaction(envelope) {
|
|
160
|
+
await assertChain();
|
|
161
|
+
for (const field of Object.keys(envelope)) if (!envelopeFields.includes(field)) throw exampleError("INVALID_ENVELOPE", `Unsupported transaction envelope field ${field}`);
|
|
162
|
+
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");
|
|
163
|
+
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");
|
|
164
|
+
const request = { ...envelope, chainId: id };
|
|
165
|
+
delete request.from;
|
|
166
|
+
for (const field of ["value", "gas", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
|
|
167
|
+
if (request[field] !== undefined) {
|
|
168
|
+
request[field] = BigInt(request[field]);
|
|
169
|
+
if (request[field] < 0n) throw exampleError("INVALID_ENVELOPE", `${field} must be unsigned`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
request.nonce = envelope.nonce === undefined
|
|
173
|
+
? exactNumber(await client.request({ method: "eth_getTransactionCount", params: [creator, "pending"] }), "nonce")
|
|
174
|
+
: exactNumber(envelope.nonce, "nonce");
|
|
175
|
+
if (request.type !== undefined) {
|
|
176
|
+
request.type = transactionTypes[request.type];
|
|
177
|
+
if (!request.type) throw exampleError("INVALID_ENVELOPE", "Only legacy/EIP-2930/EIP-1559 envelopes are supported");
|
|
178
|
+
}
|
|
179
|
+
if (request.gasPrice !== undefined && (request.maxFeePerGas !== undefined || request.maxPriorityFeePerGas !== undefined)) throw exampleError("INVALID_ENVELOPE", "Do not mix legacy and EIP-1559 fee fields");
|
|
180
|
+
if (request.gasPrice === undefined && request.maxFeePerGas === undefined && request.maxPriorityFeePerGas === undefined && request.type !== "eip1559") {
|
|
181
|
+
request.gasPrice = BigInt(await client.request({ method: "eth_gasPrice" }));
|
|
182
|
+
} else if (request.gasPrice === undefined && (request.maxFeePerGas === undefined || request.maxPriorityFeePerGas === undefined)) {
|
|
183
|
+
const fees = await publicClient.estimateFeesPerGas({ type: "eip1559" });
|
|
184
|
+
request.maxFeePerGas ??= fees.maxFeePerGas;
|
|
185
|
+
request.maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas;
|
|
186
|
+
}
|
|
187
|
+
const rawTransaction = await account.signTransaction(request);
|
|
188
|
+
redactor.addSecret(rawTransaction);
|
|
189
|
+
const wire = { ...envelope, chainId: toHex(id), nonce: toHex(request.nonce) };
|
|
190
|
+
for (const field of ["value", "gas", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) if (request[field] !== undefined) wire[field] = toHex(request[field]);
|
|
191
|
+
return { wire, rawTransaction, transactionHash: keccak256(rawTransaction) };
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Persist hash + exact unsigned envelope BEFORE the single raw broadcast attempt. */
|
|
197
|
+
export async function submitSignedRecorded({ transaction, wallet, client, wait, artifacts, signal }) {
|
|
198
|
+
signal?.throwIfAborted();
|
|
199
|
+
const signed = await wallet.signTransaction(transaction.wire);
|
|
200
|
+
artifacts.privateRecovery({ ...artifacts.recovery, signedTransactions: [
|
|
201
|
+
...(artifacts.recovery?.signedTransactions ?? []),
|
|
202
|
+
{ id: transaction.id, transactionHash: signed.transactionHash, rawTransaction: signed.rawTransaction },
|
|
203
|
+
] });
|
|
204
|
+
signal?.throwIfAborted();
|
|
205
|
+
const row = artifacts.submission({ ...transaction, wire: signed.wire }, signed.transactionHash, "broadcast-attempt");
|
|
206
|
+
// The attempt is durable even if send times out, throws, or is interrupted.
|
|
207
|
+
const returnedHash = await client.request({ method: "eth_sendRawTransaction", params: [signed.rawTransaction] });
|
|
208
|
+
if (typeof returnedHash !== "string" || returnedHash.toLowerCase() !== signed.transactionHash.toLowerCase()) {
|
|
209
|
+
throw exampleError("BROADCAST_HASH_MISMATCH", "RPC returned a hash different from the locally signed transaction", { transactionHash: signed.transactionHash, returnedHash });
|
|
210
|
+
}
|
|
211
|
+
row.status = "submitted"; row.submittedAt = new Date().toISOString();
|
|
212
|
+
artifacts.saveReceipts();
|
|
213
|
+
artifacts.event("transaction-submitted", row);
|
|
214
|
+
signal?.throwIfAborted();
|
|
215
|
+
const receipt = await wait(signed.transactionHash);
|
|
216
|
+
artifacts.inclusion(row, receipt);
|
|
217
|
+
return { row, receipt };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Public evidence retains transaction calldata and revert bytes. Secrets are identified
|
|
221
|
+
// by field names, capability syntax, URLs, and values learned before any persistence.
|
|
222
|
+
export class Redactor {
|
|
223
|
+
secrets = new Set();
|
|
224
|
+
addSecret(value) { if (typeof value === "string" && value.length > 0) this.secrets.add(value); }
|
|
225
|
+
text(value) {
|
|
226
|
+
const trimmed = value.trim();
|
|
227
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
228
|
+
try { return JSON.stringify(this.value(JSON.parse(trimmed))); } catch { /* Not a serialized JSON value. */ }
|
|
229
|
+
}
|
|
230
|
+
let result = value;
|
|
231
|
+
for (const secret of this.secrets) result = result.split(secret).join("[REDACTED]");
|
|
232
|
+
result = result.replace(/https?:\/\/[^\s<>"']+/gi, (raw) => {
|
|
233
|
+
try {
|
|
234
|
+
const url = new URL(raw);
|
|
235
|
+
const credentials = Boolean(url.username || url.password);
|
|
236
|
+
const query = Boolean(url.search);
|
|
237
|
+
url.username = ""; url.password = ""; url.search = ""; url.hash = "";
|
|
238
|
+
return `${url.href}${credentials || query ? "[REDACTED_URL_SECRET]" : ""}`;
|
|
239
|
+
} catch { return "[REDACTED_URL]"; }
|
|
240
|
+
});
|
|
241
|
+
result = result.replace(/\blus_[A-Za-z0-9_-]+\b/g, "[REDACTED]");
|
|
242
|
+
result = result.replace(/\b(?:Launch-Upload-Capability|Bearer|Basic)\s+[^\s,"'}]+/gi, "[REDACTED_AUTH]");
|
|
243
|
+
return result.replace(/((?:private[_ -]?key|signature|capability|authorization|password|secret|mnemonic|seed[_ -]?phrase|api[_ -]?key)["']?\s*[=:]\s*)(?:"[^"]*"|'[^']*'|0x[\da-f]+|[^\s,"'}]+)/gi, "$1[REDACTED]");
|
|
244
|
+
}
|
|
245
|
+
value(value) {
|
|
246
|
+
const visited = new WeakSet();
|
|
247
|
+
const visit = (item, depth) => {
|
|
248
|
+
if (depth > 30) return "[DEPTH_LIMIT]";
|
|
249
|
+
if (typeof item === "bigint") return item.toString();
|
|
250
|
+
if (typeof item === "string") return this.text(item);
|
|
251
|
+
if (typeof item === "function") return undefined;
|
|
252
|
+
if (item === null || typeof item !== "object") return item;
|
|
253
|
+
if (visited.has(item)) return "[CIRCULAR]";
|
|
254
|
+
visited.add(item);
|
|
255
|
+
if (Array.isArray(item)) {
|
|
256
|
+
const result = item.map((entry) => visit(entry, depth + 1));
|
|
257
|
+
visited.delete(item);
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
const result = {};
|
|
261
|
+
const keys = item instanceof Error ? [...new Set(["name", "message", "stack", "cause", ...Object.getOwnPropertyNames(item)])] : Object.keys(item);
|
|
262
|
+
for (const key of keys) {
|
|
263
|
+
if (/private.?key|secret|signature|capability|authorization|authentication|password|credential|mnemonic|seed.?phrase|(?:access|refresh|auth)[_-]?token|api[_-]?key|cookie/i.test(key)) result[this.text(key)] = "[REDACTED]";
|
|
264
|
+
else if (item[key] !== undefined) result[this.text(key)] = visit(item[key], depth + 1);
|
|
265
|
+
}
|
|
266
|
+
visited.delete(item);
|
|
267
|
+
return result;
|
|
268
|
+
};
|
|
269
|
+
return visit(value, 0);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function errorEvidence(error) {
|
|
274
|
+
if (!(error instanceof Error)) return { name: "UnknownError", message: String(error), cause: error };
|
|
275
|
+
const evidence = { name: error.name, code: error.code, message: error.message, stack: error.stack, cause: error.cause, revertData: error.revertData, data: error.data };
|
|
276
|
+
for (const key of Object.getOwnPropertyNames(error)) if (!(key in evidence)) evidence[key] = error[key];
|
|
277
|
+
if (evidence.revertData === undefined) evidence.revertData = findRevertData(error.data ?? error.cause);
|
|
278
|
+
return evidence;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function findRevertData(value, seen = new WeakSet()) {
|
|
282
|
+
if (typeof value === "string") return /^0x(?:[\da-f]{2})+$/i.test(value) ? value : undefined;
|
|
283
|
+
if (!value || typeof value !== "object" || seen.has(value)) return undefined;
|
|
284
|
+
seen.add(value);
|
|
285
|
+
for (const key of ["revertData", "returnValue", "output", "data", "result", "cause"]) {
|
|
286
|
+
const data = findRevertData(value[key], seen);
|
|
287
|
+
if (data) return data;
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function durableWrite(path, content, mode = 0o600) {
|
|
293
|
+
const fd = openSync(path, "w", mode);
|
|
294
|
+
try { writeFileSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export class RunArtifacts {
|
|
298
|
+
constructor(output, caseId, redactor = new Redactor()) {
|
|
299
|
+
this.directory = resolve(output);
|
|
300
|
+
mkdirSync(this.directory, { mode: 0o700 }); // EEXIST deliberately refuses reuse.
|
|
301
|
+
this.redactor = redactor;
|
|
302
|
+
this.transactions = [];
|
|
303
|
+
this.result = {
|
|
304
|
+
schema: "black-market.launch-example-result.v1", sdk: "node", caseId: caseId ?? null,
|
|
305
|
+
status: "failed", stage: "configuration", scope: "end-to-end", execution: "not-run",
|
|
306
|
+
chain: { status: "not-run", confirmations: 1, transactions: this.transactions },
|
|
307
|
+
api: { status: "not-run" }, startedAt: new Date().toISOString(), completedAt: null,
|
|
308
|
+
};
|
|
309
|
+
this.stageStartedAt = Date.now();
|
|
310
|
+
}
|
|
311
|
+
initialize() {
|
|
312
|
+
this.event("started", { directory: this.directory });
|
|
313
|
+
this.save("receipts.json", { schema: "black-market.launch-example-receipts.v1", sdk: "node", caseId: this.result.caseId, transactions: this.transactions });
|
|
314
|
+
}
|
|
315
|
+
event(event, observations = {}) {
|
|
316
|
+
const row = this.redactor.value({ timestamp: new Date().toISOString(), sdk: "node", caseId: this.result.caseId, stage: this.result.stage, event, ...observations });
|
|
317
|
+
const fd = openSync(join(this.directory, "events.jsonl"), "a", 0o600);
|
|
318
|
+
try { appendFileSync(fd, `${JSON.stringify(row)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
|
|
319
|
+
}
|
|
320
|
+
stage(stage, observations = {}) {
|
|
321
|
+
this.event("stage-completed", { elapsedMs: Date.now() - this.stageStartedAt });
|
|
322
|
+
this.result.stage = stage;
|
|
323
|
+
this.stageStartedAt = Date.now();
|
|
324
|
+
this.event("stage-started", observations);
|
|
325
|
+
}
|
|
326
|
+
save(name, value) {
|
|
327
|
+
const path = join(this.directory, name);
|
|
328
|
+
durableWrite(`${path}.tmp`, `${JSON.stringify(this.redactor.value(value), null, 2)}\n`);
|
|
329
|
+
renameSync(`${path}.tmp`, path);
|
|
330
|
+
}
|
|
331
|
+
privateRecovery(value) {
|
|
332
|
+
const path = join(this.directory, "private-recovery.json");
|
|
333
|
+
durableWrite(`${path}.tmp`, `${JSON.stringify(value, (_key, item) => typeof item === "bigint" ? item.toString() : item, 2)}\n`, 0o600);
|
|
334
|
+
renameSync(`${path}.tmp`, path);
|
|
335
|
+
this.recovery = value;
|
|
336
|
+
}
|
|
337
|
+
submission(transaction, transactionHash, status = "submitted") {
|
|
338
|
+
const row = { id: transaction.id, kind: transaction.kind, transaction: transaction.wire, transactionHash, status, attemptedAt: new Date().toISOString(), receipt: null };
|
|
339
|
+
if (status === "submitted") row.submittedAt = row.attemptedAt;
|
|
340
|
+
this.transactions.push(row);
|
|
341
|
+
this.saveReceipts();
|
|
342
|
+
this.event(status === "broadcast-attempt" ? "transaction-broadcast-attempt" : "transaction-submitted", row);
|
|
343
|
+
return row;
|
|
344
|
+
}
|
|
345
|
+
inclusion(row, receipt) {
|
|
346
|
+
row.receipt = receipt;
|
|
347
|
+
row.status = "included"; // Status/provenance is checked only after preserving it.
|
|
348
|
+
row.includedAt = new Date().toISOString();
|
|
349
|
+
this.saveReceipts();
|
|
350
|
+
this.event("transaction-included", { id: row.id, transactionHash: row.transactionHash, receipt });
|
|
351
|
+
}
|
|
352
|
+
saveReceipts() {
|
|
353
|
+
this.save("receipts.json", { schema: "black-market.launch-example-receipts.v1", sdk: "node", caseId: this.result.caseId, transactions: this.transactions });
|
|
354
|
+
}
|
|
355
|
+
finish(status, error) {
|
|
356
|
+
this.result.status = status;
|
|
357
|
+
this.result.completedAt = new Date().toISOString();
|
|
358
|
+
if (error) this.result.error = errorEvidence(error);
|
|
359
|
+
// Write the terminal result before its journal row, so a journal I/O error cannot
|
|
360
|
+
// erase a completed transaction or the original failure diagnosis.
|
|
361
|
+
this.save("result.json", this.result);
|
|
362
|
+
this.event(status, { elapsedMs: Date.now() - this.stageStartedAt, scope: this.result.scope, error: this.result.error });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
export async function pause(ms, signal) {
|
|
368
|
+
signal?.throwIfAborted();
|
|
369
|
+
await delay(ms, undefined, { signal });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function publishUntilIndexed({ publish, isPending, timeoutMs, artifacts, signal, now = Date.now, sleep = pause }) {
|
|
373
|
+
const deadline = now() + timeoutMs;
|
|
374
|
+
let attempt = 0;
|
|
375
|
+
for (;;) {
|
|
376
|
+
signal?.throwIfAborted();
|
|
377
|
+
const remainingMs = deadline - now();
|
|
378
|
+
if (remainingMs <= 0) throw exampleError("PUBLISH_TIMEOUT", "Actual API publication did not reach indexed optimistic/final state before the deadline");
|
|
379
|
+
artifacts.event("publish-request", { attempt: ++attempt, remainingMs });
|
|
380
|
+
try {
|
|
381
|
+
const session = await publish(remainingMs);
|
|
382
|
+
if (now() >= deadline) throw exampleError("PUBLISH_TIMEOUT", "The API publication response arrived after the indexing deadline");
|
|
383
|
+
return session;
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
if (!isPending(error)) throw error; // No retry of API/RPC/schema/provenance errors.
|
|
387
|
+
artifacts.result.api.status = "awaiting-indexer";
|
|
388
|
+
artifacts.result.api.session = error.session;
|
|
389
|
+
artifacts.event("publish-pending", { session: error.session, retryAfterMs: error.retryAfterMs });
|
|
390
|
+
const retryAfterMs = Number(error.retryAfterMs);
|
|
391
|
+
if (!Number.isFinite(retryAfterMs) || retryAfterMs < 0) throw exampleError("INVALID_RETRY_AFTER", "The API returned an invalid pending publication delay");
|
|
392
|
+
const availableMs = deadline - now();
|
|
393
|
+
if (availableMs <= 0) throw exampleError("PUBLISH_TIMEOUT", "Actual API publication exhausted its indexing deadline");
|
|
394
|
+
await sleep(Math.min(Math.max(250, retryAfterMs), availableMs), signal);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|