+ Activation is permanent. It creates the mint and vaults, puts all
+ supply in the pool, and removes mint authority. There is no freeze
+ authority, migration, or liquidity withdrawal.
+
+
+
+
+
+ {!isCreator && (
+
+ Connect the creator wallet to activate or cancel.
+
+ Real reserves exclude account rent and beneficiary fees. Trades use
+ the curve’s accounted balances, not unsolicited donations. SOL
+ available for sellers depends on real buyer deposits and prior
+ trading.
+
+ {pool.status === "active" && (
+
+ )}
+
+ {pool.status === "active" && (
+
+ )}
+
+ {pool.recipients.length > 0 && (
+
+
+ Fixed fee recipients
+
+
+ Anyone can trigger a claim. Payment always goes to the recipient
+ below, never to the caller by default.
+
+ No timed trades in the recent transaction window. No history is
+ drawn from the starting valuation.
+
+ )}
+
+ Recent window only, not lifetime volume or complete history.{" "}
+ {result.coverage.signaturesScanned} signatures checked.
+ {BigInt(result.throughSequence) < BigInt(sequence)
+ ? " History is catching up with the latest pool state."
+ : ""}
+ {result.coverage.sequenceGaps
+ ? " Some trade sequences are missing from this window."
+ : ""}
+ {result.coverage.unavailableTransactions > 0
+ ? " Some transactions are unavailable from the RPC."
+ : ""}
+
+ {config.cluster === "devnet"
+ ? "Devnet review. Test SOL only. Not a production release."
+ : "Native SOL pools. Fixed terms, no admin controls."}
+
+ Simulation passed. Review the same instructions in your wallet. Pool
+ creation and token-account rent are additional where listed. This
+ transaction can expire; expiry requires a new review, never an
+ automatic retry.
+
+ No active pools on this program yet. Prepare and activate
+ a launch to start a market.
+
+ )}
+
+ ) : null}
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/app/src/lib/solana/bounded-json.test.ts b/app/src/lib/solana/bounded-json.test.ts
new file mode 100644
index 00000000..3ecead00
--- /dev/null
+++ b/app/src/lib/solana/bounded-json.test.ts
@@ -0,0 +1,26 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import { boundedJson } from "./bounded-json";
+
+test("bounded JSON accepts a complete bounded response", async () => {
+ assert.deepEqual(await boundedJson(new Response('{"result":1}').body, 12), {
+ result: 1,
+ });
+});
+test("bounded JSON rejects oversized chunked input and cancels the stream", async () => {
+ let cancelled = false;
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode("12345"));
+ },
+ cancel() {
+ cancelled = true;
+ },
+ });
+ await assert.rejects(boundedJson(body, 4), /size limit/);
+ assert.equal(cancelled, true);
+});
+test("bounded JSON rejects missing and malformed input", async () => {
+ await assert.rejects(boundedJson(null, 10));
+ await assert.rejects(boundedJson(new Response("invalid").body, 10));
+});
diff --git a/app/src/lib/solana/bounded-json.ts b/app/src/lib/solana/bounded-json.ts
new file mode 100644
index 00000000..89a679a8
--- /dev/null
+++ b/app/src/lib/solana/bounded-json.ts
@@ -0,0 +1,25 @@
+/** Bound streamed input as well as Content-Length; never trust an upstream length header. */
+export async function boundedJson(
+ body: ReadableStream | null,
+ maximum: number,
+): Promise {
+ if (!body) throw new Error("Missing response body");
+ const reader = body.getReader();
+ const parts: Uint8Array[] = [];
+ let size = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > maximum) {
+ await reader.cancel();
+ throw new Error("Response exceeds size limit");
+ }
+ parts.push(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ return JSON.parse(Buffer.concat(parts).toString("utf8"));
+}
diff --git a/app/src/lib/solana/config.test.ts b/app/src/lib/solana/config.test.ts
new file mode 100644
index 00000000..886044f5
--- /dev/null
+++ b/app/src/lib/solana/config.test.ts
@@ -0,0 +1,83 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ explorerUrl,
+ formatUnits,
+ parseUnits,
+ publicKey,
+ solanaConfig,
+ type Deployment,
+} from "./config.ts";
+
+const key = "So11111111111111111111111111111111111111112";
+const blank: Deployment = {
+ schema: 1,
+ cluster: "mainnet-beta",
+ genesisHash: null,
+ programId: null,
+ programData: null,
+ sourceCommit: null,
+ binarySha256: null,
+ verifiedSlot: null,
+ securityReview: null,
+ economicReview: null,
+ releaseApproved: false,
+};
+test("Solana stays disabled without a complete approved mainnet release", () => {
+ assert.equal(solanaConfig({}, blank), null);
+ assert.equal(
+ solanaConfig(
+ {
+ SOLANA_ENABLED: "1",
+ SOLANA_CLUSTER: "mainnet-beta",
+ SOLANA_DEVNET_PROGRAM_ID: key,
+ },
+ blank,
+ ),
+ null,
+ );
+ assert.equal(
+ solanaConfig(
+ { SOLANA_ENABLED: "1", SOLANA_CLUSTER: "mainnet-beta" },
+ { ...blank, releaseApproved: true, programId: key },
+ ),
+ null,
+ );
+ assert.equal(
+ solanaConfig(
+ {
+ SOLANA_ENABLED: "1",
+ SOLANA_CLUSTER: "devnet",
+ SOLANA_DEVNET_PROGRAM_ID: key,
+ },
+ blank,
+ )?.cluster,
+ "devnet",
+ );
+});
+test("addresses remain case-sensitive and reject default, spaces and malformed keys", () => {
+ assert.equal(publicKey(key), key);
+ assert.notEqual(publicKey(key.toLowerCase()), key);
+ for (const bad of [` ${key}`, "11111111111111111111111111111111", "0x123"])
+ assert.equal(publicKey(bad), null);
+});
+test("decimal amounts never round through floating point", () => {
+ assert.equal(parseUnits("18446744073.709551615", 9), (1n << 64n) - 1n);
+ assert.equal(
+ formatUnits(parseUnits("1000000000.000001", 6), 6),
+ "1000000000.000001",
+ );
+ for (const bad of [
+ "1e9",
+ "0",
+ "-1",
+ "1,000",
+ "0.0000000001",
+ "18446744073.709551616",
+ ])
+ assert.throws(() => parseUnits(bad, 9));
+ assert.equal(
+ explorerUrl("devnet", "address", key).endsWith("?cluster=devnet"),
+ true,
+ );
+});
diff --git a/app/src/lib/solana/config.ts b/app/src/lib/solana/config.ts
new file mode 100644
index 00000000..ee8b0128
--- /dev/null
+++ b/app/src/lib/solana/config.ts
@@ -0,0 +1,110 @@
+import { PublicKey } from "@solana/web3.js";
+
+export type SolanaCluster = "devnet" | "mainnet-beta";
+export type SolanaConfig = {
+ cluster: SolanaCluster;
+ programId: string;
+ rpcPath: string;
+};
+export type Deployment = {
+ schema: number;
+ cluster: string;
+ genesisHash: string | null;
+ programId: string | null;
+ programData: string | null;
+ sourceCommit: string | null;
+ binarySha256: string | null;
+ verifiedSlot: number | null;
+ securityReview: string | null;
+ economicReview: string | null;
+ releaseApproved: boolean;
+};
+
+export function publicKey(value: unknown): string | null {
+ if (typeof value !== "string" || value.length < 32 || value.length > 44)
+ return null;
+ try {
+ const key = new PublicKey(value);
+ return key.toBase58() === value && !key.equals(PublicKey.default)
+ ? value
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+/** A mainnet env flag cannot bypass the reviewed, committed release record. */
+export function solanaConfig(
+ env: Record,
+ release: Deployment,
+): SolanaConfig | null {
+ if (env.SOLANA_ENABLED !== "1") return null;
+ if (env.SOLANA_CLUSTER === "devnet") {
+ const programId = publicKey(env.SOLANA_DEVNET_PROGRAM_ID);
+ return programId &&
+ programId !== "Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr"
+ ? { cluster: "devnet", programId, rpcPath: "/api/solana/rpc" }
+ : null;
+ }
+ if (
+ env.SOLANA_CLUSTER !== "mainnet-beta" ||
+ !release.releaseApproved ||
+ release.schema !== 1 ||
+ release.cluster !== "mainnet-beta" ||
+ release.genesisHash !== "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
+ )
+ return null;
+ const programId = publicKey(release.programId);
+ if (
+ !programId ||
+ programId === "Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr" ||
+ !publicKey(release.programData) ||
+ !/^[a-f0-9]{40}$/.test(release.sourceCommit ?? "") ||
+ !/^[a-f0-9]{64}$/.test(release.binarySha256 ?? "") ||
+ !Number.isSafeInteger(release.verifiedSlot) ||
+ (release.verifiedSlot ?? 0) <= 0 ||
+ !release.securityReview?.startsWith("https://") ||
+ !release.economicReview?.startsWith("https://")
+ )
+ return null;
+ return { cluster: "mainnet-beta", programId, rpcPath: "/api/solana/rpc" };
+}
+
+export function explorerUrl(
+ cluster: SolanaCluster,
+ kind: "address" | "tx",
+ value: string,
+): string {
+ return `https://explorer.solana.com/${kind}/${encodeURIComponent(value)}${cluster === "devnet" ? "?cluster=devnet" : ""}`;
+}
+
+export function parseUnits(value: string, decimals: number): bigint {
+ if (
+ !Number.isInteger(decimals) ||
+ decimals < 0 ||
+ decimals > 9 ||
+ value.length > 40 ||
+ !/^\d+(?:\.\d+)?$/.test(value)
+ )
+ throw new Error(
+ "Enter a positive decimal amount, without commas or exponents.",
+ );
+ const [whole, fraction = ""] = value.split(".");
+ if (fraction.length > decimals)
+ throw new Error(`Use at most ${decimals} decimal places.`);
+ const amount =
+ BigInt(whole) * 10n ** BigInt(decimals) +
+ BigInt(fraction.padEnd(decimals, "0") || "0");
+ if (amount <= 0n || amount > (1n << 64n) - 1n)
+ throw new Error("Amount is outside the supported range.");
+ return amount;
+}
+
+export function formatUnits(value: bigint, decimals: number): string {
+ const negative = value < 0n;
+ const digits = (negative ? -value : value)
+ .toString()
+ .padStart(decimals + 1, "0");
+ const fraction = decimals ? digits.slice(-decimals).replace(/0+$/, "") : "";
+ return `${negative ? "-" : ""}${decimals ? digits.slice(0, -decimals) : digits}${fraction ? `.${fraction}` : ""}`;
+}
diff --git a/app/src/lib/solana/deployment.json b/app/src/lib/solana/deployment.json
new file mode 100644
index 00000000..3da92930
--- /dev/null
+++ b/app/src/lib/solana/deployment.json
@@ -0,0 +1,13 @@
+{
+ "schema": 1,
+ "cluster": "mainnet-beta",
+ "genesisHash": null,
+ "programId": null,
+ "programData": null,
+ "sourceCommit": null,
+ "binarySha256": null,
+ "verifiedSlot": null,
+ "securityReview": null,
+ "economicReview": null,
+ "releaseApproved": false
+}
diff --git a/app/src/lib/solana/history.test.ts b/app/src/lib/solana/history.test.ts
new file mode 100644
index 00000000..cb5c3324
--- /dev/null
+++ b/app/src/lib/solana/history.test.ts
@@ -0,0 +1,417 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { Buffer } from "buffer";
+import {
+ PublicKey,
+ Message,
+ type Connection,
+ type VersionedTransactionResponse,
+} from "@solana/web3.js";
+import type { PoolSnapshot } from "../../../packages/solana-sdk/src/accounts.ts";
+import { discriminator } from "../../../packages/solana-sdk/src/encoding.ts";
+import {
+ FIXED_SUPPLY,
+ quoteBuy,
+ quoteSell,
+ type CurveState,
+} from "../../../packages/solana-sdk/src/math.ts";
+import {
+ createHistoryCache,
+ fetchRecentSolanaHistory,
+ parseSolanaSwapLogs,
+ parseSolanaTransaction,
+ recentHistoryFromEvents,
+} from "./history.ts";
+
+const program = new PublicKey(new Uint8Array(32).fill(1));
+const pool = new PublicKey(new Uint8Array(32).fill(2));
+const trader = new PublicKey(new Uint8Array(32).fill(3));
+const other = new PublicKey(new Uint8Array(32).fill(4));
+const initial: CurveState = {
+ tokenInventory: FIXED_SUPPLY,
+ realSolReserves: 0n,
+ virtualSol: 30_000_000_000n,
+ feeBps: 100,
+};
+const invoke = (key: PublicKey, depth = 1) =>
+ `Program ${key} invoke [${depth}]`;
+const success = (key: PublicKey) => `Program ${key} success`;
+const failure = (key: PublicKey) =>
+ `Program ${key} failed: custom program error: 0x1`;
+const emit = (bytes: Buffer) => `Program data: ${bytes.toString("base64")}`;
+function event(
+ state = initial,
+ sequence = 1n,
+ slot = 50n,
+ isBuy = true,
+ input = 1_000_000_000n,
+) {
+ const quote = (isBuy ? quoteBuy : quoteSell)(state, input);
+ const bytes = Buffer.alloc(129);
+ discriminator("event", "Swap").copy(bytes);
+ pool.toBuffer().copy(bytes, 8);
+ trader.toBuffer().copy(bytes, 40);
+ bytes.writeBigUInt64LE(sequence, 72);
+ bytes[80] = isBuy ? 1 : 0;
+ [
+ input,
+ quote.amountOut,
+ quote.fee,
+ quote.nextTokenInventory,
+ quote.nextRealSolReserves,
+ slot,
+ ].forEach((value, index) => bytes.writeBigUInt64LE(value, 81 + index * 8));
+ return {
+ bytes,
+ quote,
+ state: {
+ ...state,
+ tokenInventory: quote.nextTokenInventory,
+ realSolReserves: quote.nextRealSolReserves,
+ },
+ };
+}
+const first = event();
+const context = (
+ curve = first.state,
+ throughSequence = 1n,
+ blockTime: number | null = 600,
+) => ({
+ signature: "signature",
+ slot: 50,
+ blockTime,
+ pool,
+ curve,
+ throughSequence,
+});
+const snapshot = (curve = first.state, sequence = 1n): PoolSnapshot =>
+ ({ address: pool, slot: 50, pool: { ...curve, sequence } }) as PoolSnapshot;
+function transaction(
+ logs: string[],
+ options: { failed?: boolean; keys?: PublicKey[]; slot?: number } = {},
+): VersionedTransactionResponse {
+ const keys = options.keys ?? [trader, pool, program, other];
+ return {
+ slot: options.slot ?? 50,
+ blockTime: 600,
+ transaction: {
+ signatures: ["signature"],
+ message: new Message({
+ header: {
+ numRequiredSignatures: 1,
+ numReadonlySignedAccounts: 0,
+ numReadonlyUnsignedAccounts: 2,
+ },
+ accountKeys: keys,
+ recentBlockhash: trader.toBase58(),
+ instructions: [],
+ }),
+ },
+ meta: {
+ err: options.failed ? { InstructionError: [0, "InvalidArgument"] } : null,
+ fee: 5000,
+ preBalances: [],
+ postBalances: [],
+ logMessages: logs,
+ },
+ };
+}
+
+test("accept own successful top-level and CPI swaps with exact integer price", () => {
+ const logs = [
+ invoke(other),
+ invoke(program, 2),
+ emit(first.bytes),
+ success(program),
+ success(other),
+ ];
+ const parsed = parseSolanaSwapLogs(logs, program, context());
+ assert.equal(parsed.rejected, false);
+ assert.equal(parsed.events.length, 1);
+ assert.equal(parsed.events[0].trade.eventIndex, 2);
+ assert.equal(
+ parsed.events[0].trade.amountOut,
+ first.quote.amountOut.toString(),
+ );
+ assert.equal(parsed.events[0].trade.curveLamports, "990000000");
+ assert.equal(parsed.events[0].trade.priceSol, "0.00000003099");
+ assert.equal(
+ parseSolanaTransaction(transaction(logs), "signature", program, snapshot())
+ .events.length,
+ 1,
+ );
+});
+
+test("foreign emitters and log-prefixed fake runtime lines cannot impersonate own program", () => {
+ const logs = [
+ invoke(other),
+ `Program log: ${invoke(program, 2)}`,
+ emit(first.bytes),
+ `Program log: ${success(program)}`,
+ success(other),
+ ];
+ assert.deepEqual(parseSolanaSwapLogs(logs, program, context()), {
+ rejected: false,
+ events: [],
+ });
+ const sibling = [
+ invoke(other),
+ emit(first.bytes),
+ success(other),
+ invoke(program),
+ emit(first.bytes),
+ success(program),
+ ];
+ assert.equal(
+ parseSolanaSwapLogs(sibling, program, context()).events.length,
+ 1,
+ );
+});
+
+test("discard rolled-back CPI events including when an ancestor fails but outer transaction succeeds", () => {
+ const childFailure = [
+ invoke(other),
+ invoke(program, 2),
+ emit(first.bytes),
+ failure(program),
+ success(other),
+ ];
+ assert.deepEqual(parseSolanaSwapLogs(childFailure, program, context()), {
+ rejected: false,
+ events: [],
+ });
+ const ancestorFailure = [
+ invoke(trader),
+ invoke(other, 2),
+ invoke(program, 3),
+ emit(first.bytes),
+ success(program),
+ failure(other),
+ success(trader),
+ ];
+ assert.deepEqual(parseSolanaSwapLogs(ancestorFailure, program, context()), {
+ rejected: false,
+ events: [],
+ });
+ const failedTransaction = transaction(
+ [invoke(program), emit(first.bytes), success(program)],
+ { failed: true },
+ );
+ assert.equal(
+ parseSolanaTransaction(failedTransaction, "signature", program, snapshot())
+ .rejected,
+ true,
+ );
+});
+
+test("reject incomplete stacks, mismatched completions, corrupt events and truncated logs", () => {
+ const alteredAmount = Buffer.from(first.bytes);
+ alteredAmount.writeBigUInt64LE(first.quote.amountOut + 1n, 89);
+ const wrongSlot = Buffer.from(first.bytes);
+ wrongSlot.writeBigUInt64LE(51n, 121);
+ const wrongSequence = Buffer.from(first.bytes);
+ wrongSequence.writeBigUInt64LE(2n, 72);
+ const wrongBool = Buffer.from(first.bytes);
+ wrongBool[80] = 2;
+ for (const logs of [
+ [invoke(program), emit(first.bytes)],
+ [invoke(program, 2), emit(first.bytes), success(program)],
+ [invoke(program), emit(first.bytes), success(other)],
+ [emit(first.bytes)],
+ [invoke(program), emit(first.bytes), "Log truncated", success(program)],
+ [invoke(program), `${emit(first.bytes)}=`, success(program)],
+ ...[
+ first.bytes.subarray(0, 128),
+ first.bytes.subarray(0, 3),
+ Buffer.concat([first.bytes, Buffer.from([0])]),
+ alteredAmount,
+ wrongSlot,
+ wrongSequence,
+ wrongBool,
+ ].map((bytes) => [invoke(program), emit(bytes), success(program)]),
+ ])
+ assert.deepEqual(parseSolanaSwapLogs(logs, program, context()), {
+ rejected: true,
+ events: [],
+ });
+ assert.equal(
+ parseSolanaSwapLogs(
+ [invoke(program), emit(first.bytes), success(program)],
+ program,
+ context(initial),
+ ).rejected,
+ true,
+ );
+});
+
+test("reject signature, account, slot and metadata mismatches; ignore swaps for another pool", () => {
+ const logs = [invoke(program), emit(first.bytes), success(program)];
+ assert.equal(
+ parseSolanaTransaction(
+ transaction(logs),
+ "other-signature",
+ program,
+ snapshot(),
+ ).rejected,
+ true,
+ );
+ assert.equal(
+ parseSolanaTransaction(
+ transaction(logs, { keys: [trader, program] }),
+ "signature",
+ program,
+ snapshot(),
+ ).rejected,
+ true,
+ );
+ assert.equal(
+ parseSolanaTransaction(
+ transaction(logs, { keys: [trader, pool] }),
+ "signature",
+ program,
+ snapshot(),
+ ).rejected,
+ true,
+ );
+ assert.equal(
+ parseSolanaTransaction(
+ transaction(logs, { slot: 51 }),
+ "signature",
+ program,
+ snapshot(),
+ ).rejected,
+ true,
+ );
+ const missingMeta = transaction(logs);
+ missingMeta.meta = null;
+ assert.equal(
+ parseSolanaTransaction(missingMeta, "signature", program, snapshot())
+ .rejected,
+ true,
+ );
+ const foreign = Buffer.from(first.bytes);
+ other.toBuffer().copy(foreign, 8);
+ assert.deepEqual(
+ parseSolanaSwapLogs(
+ [invoke(program), emit(foreign), success(program)],
+ program,
+ context(),
+ ),
+ { rejected: false, events: [] },
+ );
+});
+
+test("deduplicate identities, reject conflicting sequences and aggregate only real timed candles", () => {
+ const second = event(first.state, 2n, 50n, false, first.quote.amountOut / 2n);
+ const a = parseSolanaSwapLogs(
+ [invoke(program), emit(first.bytes), success(program)],
+ program,
+ context(second.state, 2n),
+ ).events[0];
+ const b = parseSolanaSwapLogs(
+ [invoke(program), emit(second.bytes), success(program)],
+ program,
+ { ...context(second.state, 2n, 700), signature: "second" },
+ ).events[0];
+ const result = recentHistoryFromEvents([b, a, a], 2n);
+ assert.deepEqual(
+ result.trades.map((trade) => trade.sequence),
+ ["1", "2"],
+ );
+ assert.equal(result.sequenceGaps, false);
+ assert.equal(result.candles.length, 1);
+ assert.equal(result.candles[0].t, 600);
+ assert.equal(result.candles[0].trades, 2);
+ assert.equal(result.candles[0].open, a.trade.priceSol);
+ assert.equal(result.candles[0].close, b.trade.priceSol);
+ assert.equal(result.candles[0].volume, "1.493034923");
+ assert.deepEqual(recentHistoryFromEvents([], 0n), {
+ trades: [],
+ candles: [],
+ sequenceGaps: false,
+ });
+ const noTime = { ...a, trade: { ...a.trade, blockTime: null } };
+ assert.equal(recentHistoryFromEvents([noTime], 1n).candles.length, 0);
+ assert.equal(recentHistoryFromEvents([a], 2n).sequenceGaps, true);
+ const duplicateSequence = { ...a, trade: { ...a.trade, signature: "fork" } };
+ const conflict = recentHistoryFromEvents([a, duplicateSequence], 1n);
+ assert.equal(conflict.trades.length, 0);
+ assert.equal(conflict.sequenceGaps, true);
+ const badContinuity = { ...b, beforeSol: b.beforeSol + 1n };
+ assert.deepEqual(recentHistoryFromEvents([a, badContinuity], 2n), {
+ trades: [],
+ candles: [],
+ sequenceGaps: true,
+ });
+});
+
+test("RPC history scanning is bounded, filters failed signatures and reports unavailable transactions", async () => {
+ let concurrent = 0;
+ let maximum = 0;
+ let calls = 0;
+ const fake = {
+ getSignaturesForAddress: async (
+ _address: PublicKey,
+ options: { limit: number },
+ ) => {
+ assert.equal(options.limit, 100);
+ return Array.from({ length: 103 }, (_, i) => ({
+ signature: `sig${i}`,
+ slot: 50,
+ err: i === 0 ? {} : null,
+ memo: null,
+ confirmationStatus: "confirmed",
+ }));
+ },
+ getTransaction: async () => {
+ concurrent++;
+ maximum = Math.max(maximum, concurrent);
+ calls++;
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ concurrent--;
+ return null;
+ },
+ } as unknown as Connection;
+ const result = await fetchRecentSolanaHistory(fake, program, snapshot());
+ assert.equal(calls, 99);
+ assert(maximum <= 4);
+ assert.equal(result.coverage.signaturesScanned, 100);
+ assert.equal(result.coverage.unavailableTransactions, 99);
+ assert.equal(result.coverage.partial, true);
+ assert.equal(result.tradeCount, 0);
+ assert.deepEqual(result.candles, []);
+});
+
+test("cache deduplicates requests and limits concurrent and per-minute upstream starts", async () => {
+ let now = 60_001;
+ const cached = createHistoryCache(() => now);
+ let resolve: (n: number) => void = () => {};
+ let starts = 0;
+ const loader = () => {
+ starts++;
+ return new Promise((done) => {
+ resolve = done;
+ });
+ };
+ const a = cached("a", loader);
+ const duplicate = cached("a", loader);
+ const b = cached("b", async () => 2);
+ await assert.rejects(
+ cached("c", async () => 3),
+ /HISTORY_BUSY/,
+ );
+ await b;
+ resolve(1);
+ assert.equal(await a, 1);
+ assert.equal(await duplicate, 1);
+ assert.equal(starts, 1);
+ assert.equal(await cached("a", async () => 9), 1);
+ now += 16_000;
+ for (const key of ["c", "d", "e", "f"]) await cached(key, async () => 1);
+ await assert.rejects(
+ cached("g", async () => 1),
+ /HISTORY_BUSY/,
+ );
+ now += 60_000;
+ assert.equal(await cached("g", async () => 7), 7);
+});
diff --git a/app/src/lib/solana/history.ts b/app/src/lib/solana/history.ts
new file mode 100644
index 00000000..6b64097c
--- /dev/null
+++ b/app/src/lib/solana/history.ts
@@ -0,0 +1,490 @@
+import { Buffer } from "buffer";
+import {
+ PublicKey,
+ type Connection,
+ type VersionedTransactionResponse,
+} from "@solana/web3.js";
+import { discriminator } from "../../../packages/solana-sdk/src/encoding";
+import {
+ FIXED_SUPPLY,
+ U128_MAX,
+ U64_MAX,
+ quoteBuy,
+ quoteSell,
+ type CurveState,
+} from "../../../packages/solana-sdk/src/math";
+import type { PoolSnapshot } from "../../../packages/solana-sdk/src/accounts";
+
+export const HISTORY_SIGNATURE_LIMIT = 100;
+const SWAP_DISCRIMINATOR = discriminator("event", "Swap");
+const PRICE_SCALE = 10n ** 18n;
+const KEY = "[1-9A-HJ-NP-Za-km-z]{32,44}";
+const INVOKE = new RegExp(`^Program (${KEY}) invoke \\[(\\d+)\\]$`);
+const FINISH = new RegExp(`^Program (${KEY}) (success|failed:.*)$`);
+
+export type SolanaTrade = {
+ signature: string;
+ eventIndex: number;
+ slot: number;
+ blockTime: number | null;
+ sequence: string;
+ trader: string;
+ isBuy: boolean;
+ amountIn: string;
+ amountOut: string;
+ fee: string;
+ tokenReserve: string;
+ solReserve: string;
+ tokenAmount: string;
+ curveLamports: string;
+ priceSol: string;
+};
+export type SolanaCandle = {
+ t: number;
+ open: string;
+ high: string;
+ low: string;
+ close: string;
+ volume: string;
+ trades: number;
+};
+export type SolanaHistory = {
+ pool: string;
+ snapshotSlot: number;
+ throughSequence: string;
+ trades: SolanaTrade[];
+ candles: SolanaCandle[];
+ tradeCount: number;
+ coverage: {
+ scope: "recent";
+ partial: true;
+ signatureLimit: 100;
+ signaturesScanned: number;
+ unavailableTransactions: number;
+ rejectedTransactions: number;
+ sequenceGaps: boolean;
+ };
+};
+type EventContext = {
+ signature: string;
+ slot: number;
+ blockTime: number | null;
+ pool: PublicKey;
+ curve: CurveState;
+ throughSequence: bigint;
+};
+type RawTrade = {
+ trade: SolanaTrade;
+ price: bigint;
+ volume: bigint;
+ beforeToken: bigint;
+ beforeSol: bigint;
+};
+
+function checked(value: bigint, max = U128_MAX): bigint {
+ if (value < 0n || value > max) throw new Error("Invalid history arithmetic");
+ return value;
+}
+function decimal(value: bigint, decimals: number): string {
+ const text = checked(value)
+ .toString()
+ .padStart(decimals + 1, "0");
+ const fraction = text.slice(-decimals).replace(/0+$/, "");
+ return `${text.slice(0, -decimals)}${fraction ? `.${fraction}` : ""}`;
+}
+
+/** Event bytes are accepted only after their runtime invocation frame succeeds. */
+function decodeSwap(
+ bytes: Buffer,
+ context: EventContext,
+ eventIndex: number,
+): RawTrade | null {
+ if (!bytes.subarray(0, 8).equals(SWAP_DISCRIMINATOR)) return null;
+ if (bytes.length !== 129) throw new Error("Truncated or extended swap event");
+ const pool = new PublicKey(bytes.subarray(8, 40));
+ if (!pool.equals(context.pool)) return null;
+ const trader = new PublicKey(bytes.subarray(40, 72));
+ const sequence = bytes.readBigUInt64LE(72);
+ const side = bytes[80];
+ const amountIn = bytes.readBigUInt64LE(81);
+ const amountOut = bytes.readBigUInt64LE(89);
+ const fee = bytes.readBigUInt64LE(97);
+ const tokenReserve = bytes.readBigUInt64LE(105);
+ const solReserve = bytes.readBigUInt64LE(113);
+ const slot = bytes.readBigUInt64LE(121);
+ if (
+ sequence < 1n ||
+ sequence > context.throughSequence ||
+ slot !== BigInt(context.slot) ||
+ side > 1 ||
+ trader.equals(PublicKey.default)
+ )
+ throw new Error("Misattributed swap event");
+ const isBuy = side === 1;
+ const curveLamports = checked(
+ isBuy ? amountIn - fee : amountOut + fee,
+ U64_MAX,
+ );
+ const tokenAmount = isBuy ? amountOut : amountIn;
+ if (curveLamports === 0n || tokenAmount === 0n || tokenAmount > FIXED_SUPPLY)
+ throw new Error("Invalid swap amounts");
+ // Reconstruct the pre-trade state and check the immutable curve and fee tier.
+ const before: CurveState = {
+ ...context.curve,
+ tokenInventory: isBuy
+ ? checked(tokenReserve + amountOut, FIXED_SUPPLY)
+ : checked(tokenReserve - amountIn, FIXED_SUPPLY),
+ realSolReserves: isBuy
+ ? checked(solReserve - curveLamports, U64_MAX)
+ : checked(solReserve + curveLamports, U64_MAX),
+ };
+ const quoted = (isBuy ? quoteBuy : quoteSell)(before, amountIn);
+ if (
+ quoted.amountOut !== amountOut ||
+ quoted.fee !== fee ||
+ quoted.nextTokenInventory !== tokenReserve ||
+ quoted.nextRealSolReserves !== solReserve
+ )
+ throw new Error("Swap event disagrees with curve");
+ if (
+ sequence === context.throughSequence &&
+ (tokenReserve !== context.curve.tokenInventory ||
+ solReserve !== context.curve.realSolReserves)
+ )
+ throw new Error("Latest swap disagrees with snapshot");
+ // SOL per whole token: lamports * 10^6 / (atoms * 10^9), scaled by 10^18.
+ const price = checked(curveLamports * (PRICE_SCALE / 1000n)) / tokenAmount;
+ if (price === 0n) throw new Error("Price is below display precision");
+ return {
+ price,
+ volume: curveLamports,
+ beforeToken: before.tokenInventory,
+ beforeSol: before.realSolReserves,
+ trade: {
+ signature: context.signature,
+ eventIndex,
+ slot: context.slot,
+ blockTime: context.blockTime,
+ sequence: sequence.toString(),
+ trader: trader.toBase58(),
+ isBuy,
+ amountIn: amountIn.toString(),
+ amountOut: amountOut.toString(),
+ fee: fee.toString(),
+ tokenReserve: tokenReserve.toString(),
+ solReserve: solReserve.toString(),
+ tokenAmount: tokenAmount.toString(),
+ curveLamports: curveLamports.toString(),
+ priceSol: decimal(price, 18),
+ },
+ };
+}
+
+/** Strict runtime stack parsing prevents foreign emitters and rolled-back CPI events. */
+export function parseSolanaSwapLogs(
+ logs: readonly string[] | null | undefined,
+ programId: PublicKey,
+ context: EventContext,
+): { rejected: boolean; events: RawTrade[] } {
+ if (
+ !logs ||
+ logs.length > 10_000 ||
+ logs.reduce((size, line) => size + line.length, 0) > 256_000
+ )
+ return { rejected: true, events: [] };
+ const stack: { program: string; events: RawTrade[] }[] = [];
+ const accepted: RawTrade[] = [];
+ const ownProgram = programId.toBase58();
+ try {
+ for (let index = 0; index < logs.length; index++) {
+ const line = logs[index];
+ if (line === "Log truncated" || line.startsWith("Log truncated "))
+ throw new Error("Truncated logs");
+ const invoke = INVOKE.exec(line);
+ if (invoke) {
+ if (Number(invoke[2]) !== stack.length + 1 || stack.length >= 64)
+ throw new Error("Broken invocation depth");
+ stack.push({ program: invoke[1], events: [] });
+ continue;
+ }
+ const finish = FINISH.exec(line);
+ if (finish) {
+ const frame = stack.pop();
+ if (!frame || frame.program !== finish[1])
+ throw new Error("Mismatched program completion");
+ if (finish[2] === "success")
+ (stack.at(-1)?.events ?? accepted).push(...frame.events);
+ // Failure discards this frame and all successful child events: their writes rolled back.
+ continue;
+ }
+ if (!line.startsWith("Program data: ")) continue;
+ const frame = stack.at(-1);
+ if (!frame) throw new Error("Unscoped event log");
+ if (frame.program !== ownProgram) continue;
+ const encoded = line.slice("Program data: ".length);
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0)
+ throw new Error("Malformed event encoding");
+ const bytes = Buffer.from(encoded, "base64");
+ if (bytes.length < 8 || bytes.toString("base64") !== encoded)
+ throw new Error("Noncanonical event encoding");
+ const event = decodeSwap(bytes, context, index);
+ if (event) frame.events.push(event);
+ }
+ if (stack.length !== 0) throw new Error("Unfinished invocation logs");
+ return { rejected: false, events: accepted };
+ } catch {
+ return { rejected: true, events: [] };
+ }
+}
+
+export function parseSolanaTransaction(
+ tx: VersionedTransactionResponse,
+ signature: string,
+ programId: PublicKey,
+ snapshot: PoolSnapshot,
+): { rejected: boolean; events: RawTrade[] } {
+ if (
+ !tx.meta ||
+ tx.meta.err ||
+ tx.transaction.signatures[0] !== signature ||
+ !Number.isSafeInteger(tx.slot) ||
+ tx.slot < 0 ||
+ tx.slot > snapshot.slot
+ )
+ return { rejected: true, events: [] };
+ const keys = [
+ ...tx.transaction.message.staticAccountKeys,
+ ...(tx.meta.loadedAddresses?.writable ?? []),
+ ...(tx.meta.loadedAddresses?.readonly ?? []),
+ ];
+ if (
+ !keys.some((key) => key.equals(snapshot.address)) ||
+ !keys.some((key) => key.equals(programId))
+ )
+ return { rejected: true, events: [] };
+ const blockTime =
+ Number.isSafeInteger(tx.blockTime) && (tx.blockTime ?? -1) >= 0
+ ? tx.blockTime!
+ : null;
+ return parseSolanaSwapLogs(tx.meta.logMessages, programId, {
+ signature,
+ slot: tx.slot,
+ blockTime,
+ pool: snapshot.address,
+ curve: snapshot.pool,
+ throughSequence: snapshot.pool.sequence,
+ });
+}
+
+export function recentHistoryFromEvents(
+ events: readonly RawTrade[],
+ throughSequence: bigint,
+): { trades: SolanaTrade[]; candles: SolanaCandle[]; sequenceGaps: boolean } {
+ const identities = new Map();
+ const conflictingIds = new Set();
+ for (const event of events) {
+ const key = `${event.trade.signature}:${event.trade.eventIndex}`;
+ const seen = identities.get(key);
+ if (seen && JSON.stringify(seen.trade) !== JSON.stringify(event.trade))
+ conflictingIds.add(key);
+ else identities.set(key, event);
+ }
+ const bySequence = new Map();
+ for (const [key, event] of identities) {
+ if (conflictingIds.has(key)) continue;
+ const found = bySequence.get(event.trade.sequence) ?? [];
+ found.push(event);
+ bySequence.set(event.trade.sequence, found);
+ }
+ // Conflicting transactions for one sequence may be divergent forks. Never pick a winner.
+ const ordered = [...bySequence.values()]
+ .filter((rows) => rows.length === 1)
+ .map((rows) => rows[0])
+ .sort((a, b) =>
+ BigInt(a.trade.sequence) < BigInt(b.trade.sequence) ? -1 : 1,
+ );
+ for (let i = 1; i < ordered.length; i++) {
+ const previous = ordered[i - 1];
+ const current = ordered[i];
+ if (
+ BigInt(current.trade.sequence) === BigInt(previous.trade.sequence) + 1n &&
+ (current.beforeToken !== BigInt(previous.trade.tokenReserve) ||
+ current.beforeSol !== BigInt(previous.trade.solReserve))
+ ) {
+ // Inconsistent consecutive states cannot form one canonical history.
+ return { trades: [], candles: [], sequenceGaps: true };
+ }
+ }
+ let sequenceGaps =
+ conflictingIds.size > 0 ||
+ [...bySequence.values()].some((rows) => rows.length > 1);
+ let previous = 0n;
+ const buckets = new Map<
+ number,
+ {
+ t: number;
+ open: bigint;
+ high: bigint;
+ low: bigint;
+ close: bigint;
+ volume: bigint;
+ trades: number;
+ }
+ >();
+ for (const event of ordered) {
+ const sequence = BigInt(event.trade.sequence);
+ if (sequence !== previous + 1n) sequenceGaps = true;
+ previous = sequence;
+ if (event.trade.blockTime === null) continue; // No fabricated timestamps or candles.
+ const t = Math.floor(event.trade.blockTime / 300) * 300;
+ const bucket = buckets.get(t);
+ if (!bucket)
+ buckets.set(t, {
+ t,
+ open: event.price,
+ high: event.price,
+ low: event.price,
+ close: event.price,
+ volume: event.volume,
+ trades: 1,
+ });
+ else {
+ bucket.high = bucket.high > event.price ? bucket.high : event.price;
+ bucket.low = bucket.low < event.price ? bucket.low : event.price;
+ bucket.close = event.price;
+ bucket.volume = checked(bucket.volume + event.volume);
+ bucket.trades++;
+ }
+ }
+ if (previous !== throughSequence) sequenceGaps = true;
+ return {
+ trades: ordered.map((event) => event.trade),
+ sequenceGaps,
+ candles: [...buckets.values()]
+ .sort((a, b) => a.t - b.t)
+ .map((bucket) => ({
+ t: bucket.t,
+ open: decimal(bucket.open, 18),
+ high: decimal(bucket.high, 18),
+ low: decimal(bucket.low, 18),
+ close: decimal(bucket.close, 18),
+ volume: decimal(bucket.volume, 9),
+ trades: bucket.trades,
+ })),
+ };
+}
+
+export async function fetchRecentSolanaHistory(
+ connection: Connection,
+ programId: PublicKey,
+ snapshot: PoolSnapshot,
+): Promise {
+ const signatures = (
+ await connection.getSignaturesForAddress(
+ snapshot.address,
+ { limit: HISTORY_SIGNATURE_LIMIT, minContextSlot: snapshot.slot },
+ "confirmed",
+ )
+ ).slice(0, HISTORY_SIGNATURE_LIMIT);
+ const unique = [
+ ...new Map(
+ signatures.map((signature) => [signature.signature, signature]),
+ ).values(),
+ ];
+ const candidates = unique.filter(
+ (signature) =>
+ !signature.err &&
+ signature.slot <= snapshot.slot &&
+ signature.confirmationStatus !== "processed",
+ );
+ let unavailableTransactions = 0;
+ let rejectedTransactions = 0;
+ let next = 0;
+ const deadline = Date.now() + 10_000;
+ const events: RawTrade[] = [];
+ // Each request uses at most four RPCs concurrently; route also bounds total live requests.
+ await Promise.all(
+ Array.from({ length: Math.min(4, candidates.length) }, async () => {
+ while (next < candidates.length) {
+ if (Date.now() >= deadline) {
+ unavailableTransactions += candidates.length - next;
+ next = candidates.length;
+ break;
+ }
+ const signature = candidates[next++];
+ try {
+ const tx = await connection.getTransaction(signature.signature, {
+ commitment: "confirmed",
+ maxSupportedTransactionVersion: 0,
+ });
+ if (!tx) {
+ unavailableTransactions++;
+ continue;
+ }
+ const parsed = parseSolanaTransaction(
+ tx,
+ signature.signature,
+ programId,
+ snapshot,
+ );
+ if (parsed.rejected || tx.slot !== signature.slot)
+ rejectedTransactions++;
+ else events.push(...parsed.events);
+ } catch {
+ unavailableTransactions++;
+ }
+ }
+ }),
+ );
+ const history = recentHistoryFromEvents(events, snapshot.pool.sequence);
+ return {
+ pool: snapshot.address.toBase58(),
+ snapshotSlot: snapshot.slot,
+ throughSequence: snapshot.pool.sequence.toString(),
+ trades: history.trades,
+ candles: history.candles,
+ tradeCount: history.trades.length,
+ coverage: {
+ scope: "recent",
+ partial: true,
+ signatureLimit: HISTORY_SIGNATURE_LIMIT,
+ signaturesScanned: unique.length,
+ unavailableTransactions,
+ rejectedTransactions,
+ sequenceGaps: history.sequenceGaps,
+ },
+ };
+}
+
+/** Per-process load shedding, not a distributed quota or durable indexer. */
+export function createHistoryCache(now: () => number = Date.now) {
+ const cache = new Map();
+ const pending = new Map>();
+ let windowAt = 0;
+ let starts = 0;
+ return async (key: string, load: () => Promise): Promise => {
+ const time = now();
+ const found = cache.get(key);
+ if (found && found.expires > time) return found.value;
+ const existing = pending.get(key);
+ if (existing) return existing;
+ if (time - windowAt >= 60_000) {
+ windowAt = time;
+ starts = 0;
+ }
+ if (pending.size >= 2 || starts >= 6) throw new Error("HISTORY_BUSY");
+ starts++;
+ const task = Promise.resolve()
+ .then(load)
+ .then((value) => {
+ for (const [id, entry] of cache)
+ if (entry.expires <= now()) cache.delete(id);
+ if (cache.size >= 128) cache.delete(cache.keys().next().value!);
+ cache.set(key, { expires: now() + 15_000, value });
+ return value;
+ })
+ .finally(() => pending.delete(key));
+ pending.set(key, task);
+ return task;
+ };
+}
diff --git a/app/src/lib/solana/journal.test.ts b/app/src/lib/solana/journal.test.ts
new file mode 100644
index 00000000..7283f78b
--- /dev/null
+++ b/app/src/lib/solana/journal.test.ts
@@ -0,0 +1,210 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ clearJournalIfCurrent,
+ journalKey,
+ persistJournalForSession,
+ readJournal,
+ sameActionContext,
+ samePendingAction,
+ sameReviewScope,
+ type PendingSolanaAction,
+} from "./journal.ts";
+
+const record: PendingSolanaAction = {
+ version: 1,
+ programId: "BPFLoaderUpgradeab1e11111111111111111111111",
+ cluster: "devnet",
+ wallet: "11111111111111111111111111111111",
+ signature: "1".repeat(64),
+ lastValidBlockHeight: 100,
+ label: "Buy tokens",
+ pool: "11111111111111111111111111111111",
+};
+const key = journalKey(record.programId, record.cluster, record.wallet);
+const read = (value: unknown) =>
+ readJournal(
+ JSON.stringify(value),
+ record.programId,
+ record.cluster,
+ record.wallet,
+ );
+
+test("journal is bound to program, cluster and case-sensitive wallet identity", () => {
+ assert.deepEqual(read(record), record);
+ for (const field of ["programId", "cluster", "wallet"] as const)
+ assert.equal(read({ ...record, [field]: `${record[field]}x` }), null);
+ assert.equal(read({ ...record, lastValidBlockHeight: 0 }), null);
+ assert.equal(
+ read({ ...record, lastValidBlockHeight: Number.MAX_SAFE_INTEGER + 1 }),
+ null,
+ );
+ assert.equal(read({ ...record, label: "\nBuy" }), null);
+ assert.equal(
+ read({ ...record, signature: "z".repeat(88) }),
+ null,
+ "regex-shaped text is not necessarily 64 decoded bytes",
+ );
+ assert.equal(
+ read({ ...record, pool: "z".repeat(44) }),
+ null,
+ "pool must decode to exactly 32 bytes",
+ );
+ assert.equal(
+ readJournal(
+ " ".repeat(4097),
+ record.programId,
+ record.cluster,
+ record.wallet,
+ ),
+ null,
+ );
+ assert.equal(
+ readJournal("broken", record.programId, record.cluster, record.wallet),
+ null,
+ );
+});
+test("new wallet or reconnect cannot inherit ready state from the old session", () => {
+ const walletA = {};
+ const walletB = {};
+ assert.equal(
+ sameActionContext({ key, session: walletA }, { key, session: walletA }),
+ true,
+ );
+ assert.equal(
+ sameActionContext(
+ { key, session: walletA },
+ { key: `${key}:another-wallet`, session: walletB },
+ ),
+ false,
+ );
+ assert.equal(
+ sameActionContext({ key, session: walletA }, { key, session: walletB }),
+ false,
+ "same address reconnect still requires hydration",
+ );
+ assert.equal(
+ sameActionContext({ key, session: walletA }, { key, session: null }),
+ false,
+ );
+});
+test("stale confirmation never removes a newer transaction journal", () => {
+ const next = { ...record, signature: "1".repeat(63) + "2" };
+ let raw: string | null = JSON.stringify(next);
+ let removals = 0;
+ const storage = {
+ getItem: (target: string) => {
+ assert.equal(target, key);
+ return raw;
+ },
+ removeItem: (target: string) => {
+ assert.equal(target, key);
+ raw = null;
+ removals++;
+ },
+ };
+ assert.equal(clearJournalIfCurrent(storage, key, record), false);
+ assert.equal(removals, 0);
+ assert.equal(raw, JSON.stringify(next));
+ assert.equal(clearJournalIfCurrent(storage, key, next), true);
+ assert.equal(removals, 1);
+ assert.equal(raw, null);
+ assert.equal(clearJournalIfCurrent(storage, key, next), false);
+});
+test("same signature cannot hide changed expiry or recovery metadata", () => {
+ assert.equal(samePendingAction(record, { ...record }), true);
+ assert.equal(
+ samePendingAction(record, { ...record, lastValidBlockHeight: 101 }),
+ false,
+ );
+ assert.equal(samePendingAction(record, { ...record, pool: null }), false);
+ assert.equal(samePendingAction(null, record), false);
+});
+
+test("post-sign wallet invalidation and failed persistence stop the broadcast boundary", () => {
+ let raw: string | null = null;
+ let writes = 0;
+ const storage = {
+ getItem: () => raw,
+ setItem: (_key: string, value: string) => {
+ writes++;
+ raw = value;
+ },
+ };
+ assert.throws(
+ () => persistJournalForSession(storage, key, record, () => false),
+ /Wallet changed/,
+ );
+ assert.equal(writes, 0);
+ assert.throws(
+ () =>
+ persistJournalForSession(
+ { getItem: () => null, setItem: () => undefined },
+ key,
+ record,
+ () => true,
+ ),
+ /persist/,
+ );
+ assert.throws(
+ () =>
+ persistJournalForSession(
+ {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("Storage blocked");
+ },
+ },
+ key,
+ record,
+ () => true,
+ ),
+ /Storage blocked/,
+ );
+ persistJournalForSession(storage, key, record, () => true);
+ assert.deepEqual(
+ readJournal(raw, record.programId, record.cluster, record.wallet),
+ record,
+ );
+});
+
+test("pool/view changes discard old preparation without hiding wallet-keyed recovery", () => {
+ const oldPool = { value: "pool-A:explore:0" };
+ const otherPool = { value: "pool-B:explore:0" };
+ const returnToPool = { value: "pool-A:explore:0" };
+ const session = {};
+ assert.equal(sameReviewScope(oldPool, oldPool), true);
+ assert.equal(sameReviewScope(oldPool, otherPool), false);
+ assert.equal(
+ sameReviewScope(oldPool, returnToPool),
+ false,
+ "navigating back must not revive a stale request",
+ );
+ assert.equal(
+ sameReviewScope(oldPool, null),
+ false,
+ "unmounted scope cannot sign",
+ );
+ assert.equal(
+ sameActionContext({ key, session }, { key, session }),
+ true,
+ "pending remains bound to wallet, not selected pool",
+ );
+ let writes = 0;
+ assert.throws(
+ () =>
+ persistJournalForSession(
+ {
+ getItem: () => null,
+ setItem: () => {
+ writes++;
+ },
+ },
+ key,
+ record,
+ () => sameReviewScope(oldPool, otherPool),
+ ),
+ /changed/,
+ );
+ assert.equal(writes, 0);
+});
diff --git a/app/src/lib/solana/journal.ts b/app/src/lib/solana/journal.ts
new file mode 100644
index 00000000..88c5f58b
--- /dev/null
+++ b/app/src/lib/solana/journal.ts
@@ -0,0 +1,148 @@
+export type PendingSolanaAction = {
+ version: 1;
+ programId: string;
+ cluster: string;
+ wallet: string;
+ signature: string;
+ lastValidBlockHeight: number;
+ label: string;
+ pool: string | null;
+};
+
+export function journalKey(programId: string, cluster: string, wallet: string) {
+ return `openlaunch:solana:v1:${cluster}:${programId}:${wallet}`;
+}
+/** A new connection to the same address is still a different signing session. */
+export type ActionContext = { key: string; session: object | null };
+export function sameActionContext(
+ expected: ActionContext,
+ current: ActionContext,
+): boolean {
+ return expected.key === current.key && expected.session === current.session;
+}
+/** Per-view identity is intentionally separate from wallet-keyed recovery. */
+export type ReviewScope = { value: string };
+export function sameReviewScope(
+ expected: ReviewScope,
+ current: ReviewScope | null,
+): boolean {
+ // Identity, not just text: A -> B -> A must not revive the first A's request.
+ return expected === current;
+}
+function base58Bytes(value: string, size: number): boolean {
+ const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+ if (!value || value.length > 88 || !/^[1-9A-HJ-NP-Za-km-z]+$/.test(value))
+ return false;
+ let number = 0n;
+ for (const char of value)
+ number = number * 58n + BigInt(alphabet.indexOf(char));
+ let length = 0;
+ while (number > 0n) {
+ length++;
+ number >>= 8n;
+ }
+ for (const char of value) {
+ if (char !== "1") break;
+ length++;
+ }
+ return length === size;
+}
+export function readJournal(
+ raw: string | null,
+ programId: string,
+ cluster: string,
+ wallet: string,
+): PendingSolanaAction | null {
+ if (!raw || raw.length > 4096) return null;
+ try {
+ const item: unknown = JSON.parse(raw);
+ if (!item || typeof item !== "object") return null;
+ const p = item as Partial;
+ if (
+ p.version !== 1 ||
+ p.programId !== programId ||
+ p.cluster !== cluster ||
+ p.wallet !== wallet ||
+ typeof p.signature !== "string" ||
+ !base58Bytes(p.signature, 64) ||
+ !Number.isSafeInteger(p.lastValidBlockHeight) ||
+ (p.lastValidBlockHeight ?? 0) <= 0 ||
+ typeof p.label !== "string" ||
+ !p.label.trim() ||
+ p.label.length > 100 ||
+ /\p{Cc}/u.test(p.label) ||
+ !(
+ p.pool === null ||
+ (typeof p.pool === "string" && base58Bytes(p.pool, 32))
+ )
+ )
+ return null;
+ return p as PendingSolanaAction;
+ } catch {
+ return null;
+ }
+}
+
+export function samePendingAction(
+ a: PendingSolanaAction | null,
+ b: PendingSolanaAction,
+): boolean {
+ return Boolean(
+ a &&
+ a.version === b.version &&
+ a.programId === b.programId &&
+ a.cluster === b.cluster &&
+ a.wallet === b.wallet &&
+ a.signature === b.signature &&
+ a.lastValidBlockHeight === b.lastValidBlockHeight &&
+ a.label === b.label &&
+ a.pool === b.pool,
+ );
+}
+
+/** Call while holding the journal's Web Lock. Never delete a newer action after a stale RPC response. */
+export function clearJournalIfCurrent(
+ storage: Pick,
+ key: string,
+ expected: PendingSolanaAction,
+): boolean {
+ const current = readJournal(
+ storage.getItem(key),
+ expected.programId,
+ expected.cluster,
+ expected.wallet,
+ );
+ if (!samePendingAction(current, expected)) return false;
+ storage.removeItem(key);
+ return true;
+}
+
+/** Hold the journal Web Lock and call synchronously immediately before broadcast. */
+export function persistJournalForSession(
+ storage: Pick,
+ key: string,
+ record: PendingSolanaAction,
+ isCurrent: () => boolean,
+): void {
+ if (!isCurrent())
+ throw new Error("Wallet changed while signing. Nothing was broadcast.");
+ storage.setItem(key, JSON.stringify(record));
+ if (
+ !samePendingAction(
+ readJournal(
+ storage.getItem(key),
+ record.programId,
+ record.cluster,
+ record.wallet,
+ ),
+ record,
+ )
+ )
+ throw new Error(
+ "Could not persist transaction recovery. Nothing was broadcast.",
+ );
+ if (!isCurrent())
+ throw new Error(
+ "Wallet changed before broadcast. The signed transaction is retained for status checks.",
+ );
+}
diff --git a/app/src/lib/solana/rpc-budget.test.ts b/app/src/lib/solana/rpc-budget.test.ts
new file mode 100644
index 00000000..5900a21e
--- /dev/null
+++ b/app/src/lib/solana/rpc-budget.test.ts
@@ -0,0 +1,18 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { rpcBudget } from "./rpc-budget";
+
+test("RPC budget caps concurrency, duplicate release, and starts across IPs", () => {
+ const acquire = rpcBudget(1, 2);
+ const release = acquire(60_000);
+ assert.ok(release);
+ assert.equal(acquire(60_000), null);
+ release();
+ release();
+ const second = acquire(60_001);
+ assert.ok(second);
+ assert.equal(acquire(60_001), null);
+ second();
+ assert.equal(acquire(60_002), null);
+ assert.ok(acquire(120_000));
+});
diff --git a/app/src/lib/solana/rpc-budget.ts b/app/src/lib/solana/rpc-budget.ts
new file mode 100644
index 00000000..0199cc17
--- /dev/null
+++ b/app/src/lib/solana/rpc-budget.ts
@@ -0,0 +1,22 @@
+/** Per-process backstop across IPs. Production also needs provider/edge quotas. */
+export function rpcBudget(maximumActive = 12, startsPerMinute = 300) {
+ let active = 0;
+ let starts = 0;
+ let windowStart = 0;
+ return function acquire(now = Date.now()): (() => void) | null {
+ if (now - windowStart >= 60_000) {
+ windowStart = now;
+ starts = 0;
+ }
+ if (active >= maximumActive || starts >= startsPerMinute) return null;
+ active++;
+ starts++;
+ let released = false;
+ return () => {
+ if (!released) {
+ released = true;
+ active--;
+ }
+ };
+ };
+}
diff --git a/app/src/lib/solana/rpc-policy.test.ts b/app/src/lib/solana/rpc-policy.test.ts
new file mode 100644
index 00000000..d1460dc4
--- /dev/null
+++ b/app/src/lib/solana/rpc-policy.test.ts
@@ -0,0 +1,146 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ Keypair,
+ PublicKey,
+ SystemProgram,
+ Transaction,
+ TransactionInstruction,
+} from "@solana/web3.js";
+import { Buffer } from "buffer";
+import { allowedSolanaRpc } from "./rpc-policy.ts";
+
+const program = new PublicKey("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
+const request = (method: string, params: unknown[] = []) => ({
+ jsonrpc: "2.0",
+ id: 1,
+ method,
+ params,
+});
+test("RPC rejects broad relay methods and oversized account/history batches", () => {
+ assert.equal(
+ allowedSolanaRpc(request("requestAirdrop"), program.toBase58()),
+ false,
+ );
+ assert.equal(
+ allowedSolanaRpc(
+ request("getProgramAccounts", [SystemProgram.programId.toBase58()]),
+ program.toBase58(),
+ ),
+ false,
+ );
+ assert.equal(
+ allowedSolanaRpc(
+ request("getMultipleAccounts", [Array(17).fill(program.toBase58())]),
+ program.toBase58(),
+ ),
+ false,
+ );
+ assert.equal(
+ allowedSolanaRpc(
+ request("getSignaturesForAddress", [program.toBase58(), { limit: 101 }]),
+ program.toBase58(),
+ ),
+ false,
+ );
+ assert.equal(
+ allowedSolanaRpc(
+ request("getSignaturesForAddress", [program.toBase58(), { limit: 100 }]),
+ program.toBase58(),
+ ),
+ false,
+ );
+ for (const method of ["getTransaction", "getTokenAccountsByOwner"])
+ assert.equal(
+ allowedSolanaRpc(
+ request(method, [program.toBase58()]),
+ program.toBase58(),
+ ),
+ false,
+ );
+ assert.equal(
+ allowedSolanaRpc(request("getBalance", ["not-a-key"]), program.toBase58()),
+ false,
+ );
+ assert.equal(
+ allowedSolanaRpc(
+ request("getFeeForMessage", ["x".repeat(1645)]),
+ program.toBase58(),
+ ),
+ false,
+ );
+ assert.equal(
+ allowedSolanaRpc(
+ request("getLatestBlockhash", [{ commitment: "confirmed" }]),
+ program.toBase58(),
+ ),
+ true,
+ );
+});
+test("RPC sends only fully signed program transactions; simulation never signs", () => {
+ const signer = Keypair.generate(); // ephemeral, offline test only; never persisted
+ const tx = new Transaction({
+ feePayer: signer.publicKey,
+ recentBlockhash: program.toBase58(),
+ }).add(
+ new TransactionInstruction({
+ programId: program,
+ keys: [],
+ data: Buffer.alloc(8),
+ }),
+ );
+ const wire = () =>
+ tx
+ .serialize({ requireAllSignatures: false, verifySignatures: false })
+ .toString("base64");
+ assert.equal(
+ allowedSolanaRpc(
+ request("sendTransaction", [wire(), { encoding: "base64" }]),
+ program.toBase58(),
+ ),
+ false,
+ );
+ assert.equal(
+ allowedSolanaRpc(
+ request("simulateTransaction", [
+ wire(),
+ { encoding: "base64", sigVerify: false },
+ ]),
+ program.toBase58(),
+ ),
+ true,
+ );
+ tx.sign(signer);
+ assert.equal(
+ allowedSolanaRpc(
+ request("sendTransaction", [wire(), { encoding: "base64" }]),
+ program.toBase58(),
+ ),
+ true,
+ );
+ assert.equal(
+ allowedSolanaRpc(
+ request("sendTransaction", [
+ wire(),
+ { encoding: "base64", skipPreflight: true },
+ ]),
+ program.toBase58(),
+ ),
+ false,
+ );
+ tx.instructions = [
+ SystemProgram.transfer({
+ fromPubkey: signer.publicKey,
+ toPubkey: program,
+ lamports: 1,
+ }),
+ ];
+ tx.sign(signer);
+ assert.equal(
+ allowedSolanaRpc(
+ request("sendTransaction", [wire(), { encoding: "base64" }]),
+ program.toBase58(),
+ ),
+ false,
+ );
+});
diff --git a/app/src/lib/solana/rpc-policy.ts b/app/src/lib/solana/rpc-policy.ts
new file mode 100644
index 00000000..7ce6a10d
--- /dev/null
+++ b/app/src/lib/solana/rpc-policy.ts
@@ -0,0 +1,114 @@
+import { Transaction } from "@solana/web3.js";
+import { Buffer } from "buffer";
+import { publicKey } from "./config";
+
+const READS = new Set([
+ "getAccountInfo",
+ "getMultipleAccounts",
+ "getBalance",
+ "getLatestBlockhash",
+ "getBlockHeight",
+ "getSlot",
+ "getGenesisHash",
+ "getFeeForMessage",
+ "getMinimumBalanceForRentExemption",
+ "getSignatureStatuses",
+ "getProgramAccounts",
+]);
+export type RpcRequest = {
+ jsonrpc: "2.0";
+ id: string | number;
+ method: string;
+ params: unknown[];
+};
+
+/** The proxy is not a general RPC relay. No airdrops, subscriptions, or arbitrary broadcasts. */
+export function allowedSolanaRpc(
+ value: unknown,
+ programId: string,
+): value is RpcRequest {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const r = value as Partial;
+ if (
+ r.jsonrpc !== "2.0" ||
+ !(typeof r.id === "number" || typeof r.id === "string") ||
+ typeof r.method !== "string" ||
+ !Array.isArray(r.params) ||
+ r.params.length > 4
+ )
+ return false;
+ if (r.method === "getProgramAccounts" && r.params[0] !== programId)
+ return false;
+ if (
+ ["getAccountInfo", "getBalance"].includes(r.method) &&
+ !publicKey(r.params[0])
+ )
+ return false;
+ if (
+ r.method === "getFeeForMessage" &&
+ (typeof r.params[0] !== "string" ||
+ r.params[0].length > 1644 ||
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(r.params[0]))
+ )
+ return false;
+ if (
+ r.method === "getMultipleAccounts" &&
+ (!Array.isArray(r.params[0]) ||
+ r.params[0].length > 16 ||
+ r.params[0].some((key) => !publicKey(key)))
+ )
+ return false;
+ if (
+ r.method === "getSignatureStatuses" &&
+ (!Array.isArray(r.params[0]) ||
+ r.params[0].length > 16 ||
+ r.params[0].some(
+ (s) =>
+ typeof s !== "string" || !/^[1-9A-HJ-NP-Za-km-z]{64,88}$/.test(s),
+ ))
+ )
+ return false;
+ if (
+ r.method === "getMinimumBalanceForRentExemption" &&
+ (!Number.isSafeInteger(r.params[0]) ||
+ Number(r.params[0]) < 0 ||
+ Number(r.params[0]) > 4096)
+ )
+ return false;
+ if (READS.has(r.method)) return true;
+ if (r.method !== "sendTransaction" && r.method !== "simulateTransaction")
+ return false;
+ const encoded = r.params[0];
+ const options = r.params[1] as
+ | { encoding?: string; skipPreflight?: boolean; sigVerify?: boolean }
+ | undefined;
+ if (
+ typeof encoded !== "string" ||
+ encoded.length > 1644 ||
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) ||
+ options?.encoding !== "base64"
+ )
+ return false;
+ try {
+ const bytes = Buffer.from(encoded, "base64");
+ if (bytes.length > 1232 || bytes.toString("base64") !== encoded)
+ return false;
+ const tx = Transaction.from(bytes);
+ if (!tx.instructions.some((ix) => ix.programId.toBase58() === programId))
+ return false;
+ const approved = new Set([
+ programId,
+ "11111111111111111111111111111111",
+ "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
+ "ComputeBudget111111111111111111111111111111",
+ ]);
+ if (tx.instructions.some((ix) => !approved.has(ix.programId.toBase58())))
+ return false;
+ return (
+ r.method === "simulateTransaction" ||
+ (options.skipPreflight !== true && tx.verifySignatures())
+ );
+ } catch {
+ return false;
+ }
+}
diff --git a/app/src/lib/solana/server.ts b/app/src/lib/solana/server.ts
new file mode 100644
index 00000000..2d424d17
--- /dev/null
+++ b/app/src/lib/solana/server.ts
@@ -0,0 +1,37 @@
+import "server-only";
+import { Connection } from "@solana/web3.js";
+import deployment from "./deployment.json";
+import { solanaConfig } from "./config";
+
+export function configuredSolana() {
+ return solanaConfig(process.env, deployment);
+}
+
+export function solanaRpcUrl(): string | null {
+ const config = configuredSolana();
+ if (!config) return null;
+ const raw = process.env.SOLANA_RPC_URL?.trim();
+ if (!raw)
+ return config.cluster === "devnet" ? "https://api.devnet.solana.com" : null;
+ try {
+ const url = new URL(raw);
+ return url.protocol === "https:" ? url.href : null;
+ } catch {
+ return null;
+ }
+}
+
+export function solanaConnection(): Connection {
+ const url = solanaRpcUrl();
+ if (!url) throw new Error("Solana RPC is not configured.");
+ return new Connection(url, {
+ commitment: "confirmed",
+ disableRetryOnRateLimit: true,
+ fetch: (input, init) =>
+ fetch(input, {
+ ...init,
+ signal: AbortSignal.timeout(15_000),
+ cache: "no-store",
+ }),
+ });
+}
diff --git a/app/src/lib/solana/useSolanaAction.ts b/app/src/lib/solana/useSolanaAction.ts
new file mode 100644
index 00000000..11b38b49
--- /dev/null
+++ b/app/src/lib/solana/useSolanaAction.ts
@@ -0,0 +1,476 @@
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ Connection,
+ PublicKey,
+ type TransactionInstruction,
+} from "@solana/web3.js";
+import {
+ prepareTransaction,
+ simulatePreparedTransaction,
+ validateSignedTransaction,
+ submitSignedTransaction,
+ reconcileTransaction,
+ verifyImmutableProgram,
+ type PreparedTransaction,
+} from "@openlaunch/solana-sdk";
+import type { SolanaConfig } from "./config";
+import {
+ clearJournalIfCurrent,
+ journalKey,
+ persistJournalForSession,
+ readJournal,
+ sameActionContext,
+ samePendingAction,
+ sameReviewScope,
+ type ActionContext,
+ type PendingSolanaAction,
+ type ReviewScope,
+} from "./journal";
+import type { WalletSession } from "./wallet";
+
+export type ActionReview = {
+ label: string;
+ lines: [string, string][];
+ prepared: PreparedTransaction;
+ payer: string;
+ pool: string | null;
+ networkFee: number;
+};
+type Recovery = {
+ context: ActionContext;
+ pending: PendingSolanaAction | null;
+ ready: boolean;
+ error: string;
+};
+type Notice = { context: ActionContext; message: string; error: string };
+
+export function useSolanaAction(
+ config: SolanaConfig,
+ connection: Connection,
+ session: WalletSession | null,
+ sign: (wire: Uint8Array) => Promise,
+ onSettled: (pool: string | null) => void,
+ reviewScope: string,
+) {
+ const address = session?.account.address ?? "";
+ const key = journalKey(config.programId, config.cluster, address);
+ const context = useMemo(() => ({ key, session }), [key, session]);
+ const scope = useMemo(
+ () => ({ value: reviewScope }),
+ [reviewScope],
+ );
+ const [reviewState, setReview] = useState<{
+ context: ActionContext;
+ scope: ReviewScope;
+ value: ActionReview;
+ } | null>(null);
+ const [recovery, setRecovery] = useState(null);
+ const [notice, setNotice] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const gate = useRef(false);
+ const checks = useRef(new Set());
+ const currentContext = useRef(context);
+ const currentScope = useRef(scope);
+ const settled = useRef(onSettled);
+ useLayoutEffect(() => {
+ currentContext.current = context;
+ settled.current = onSettled;
+ return () => {
+ currentContext.current = { key: "", session: null };
+ };
+ }, [context, onSettled]);
+ useLayoutEffect(() => {
+ currentScope.current = scope;
+ return () => {
+ currentScope.current = null;
+ };
+ }, [scope]);
+
+ // A prior wallet's ready flag cannot survive one render of a new identity.
+ // Reconnecting the same address also creates a fresh signing session.
+ const recovered =
+ recovery && sameActionContext(recovery.context, context) ? recovery : null;
+ const restored = recovered?.ready ?? false;
+ const pending = recovered?.pending ?? null;
+ const review =
+ restored &&
+ reviewState &&
+ sameActionContext(reviewState.context, context) &&
+ sameReviewScope(reviewState.scope, scope)
+ ? reviewState.value
+ : null;
+ const visibleNotice =
+ notice && sameActionContext(notice.context, context) ? notice : null;
+ const message = visibleNotice?.message ?? "";
+ const error = recovered?.error || visibleNotice?.error || "";
+
+ const live = useCallback(
+ () =>
+ sameActionContext(context, currentContext.current) &&
+ Boolean(session?.isCurrent()) &&
+ session?.chain ===
+ (config.cluster === "devnet" ? "solana:devnet" : "solana:mainnet"),
+ [context, session, config.cluster],
+ );
+ const reviewLive = useCallback(
+ () => live() && sameReviewScope(scope, currentScope.current),
+ [live, scope],
+ );
+ const setError = useCallback(
+ (value: string) => {
+ if (!sameActionContext(context, currentContext.current)) return;
+ setNotice((old) => ({
+ context,
+ message:
+ old && sameActionContext(old.context, context) ? old.message : "",
+ error: value,
+ }));
+ },
+ [context],
+ );
+ const setMessage = useCallback(
+ (value: string) => {
+ if (!sameActionContext(context, currentContext.current)) return;
+ setNotice((old) => ({
+ context,
+ message: value,
+ error: old && sameActionContext(old.context, context) ? old.error : "",
+ }));
+ },
+ [context],
+ );
+
+ const restore = useCallback(() => {
+ if (!sameActionContext(context, currentContext.current)) return;
+ if (!address) {
+ setRecovery({ context, pending: null, ready: true, error: "" });
+ return;
+ }
+ try {
+ const raw = localStorage.getItem(key);
+ const record = readJournal(
+ raw,
+ config.programId,
+ config.cluster,
+ address,
+ );
+ if (raw && !record)
+ throw new Error(
+ "The transaction recovery record is unreadable. Inspect your wallet history before clearing site storage or signing again.",
+ );
+ setRecovery((old) => ({
+ context,
+ // Storage deletion by another tab is not proof of finality. Retain our
+ // known signature until our own reconciliation reaches a terminal state.
+ pending:
+ record ??
+ (old && sameActionContext(old.context, context) ? old.pending : null),
+ ready: true,
+ error: "",
+ }));
+ } catch (e) {
+ setRecovery((old) => ({
+ context,
+ pending:
+ old && sameActionContext(old.context, context) ? old.pending : null,
+ ready: false,
+ error:
+ e instanceof Error
+ ? e.message
+ : "Browser storage is unavailable. Signing is disabled.",
+ }));
+ }
+ }, [context, address, key, config.programId, config.cluster]);
+ useEffect(() => {
+ const timer = setTimeout(restore, 0);
+ const storage = (event: StorageEvent) => {
+ if (event.key === key || event.key === null) restore();
+ };
+ window.addEventListener("storage", storage);
+ return () => {
+ clearTimeout(timer);
+ window.removeEventListener("storage", storage);
+ };
+ }, [restore, key]);
+
+ const check = useCallback(async () => {
+ if (!pending || pending.wallet !== address || !live()) return;
+ const checkId = key + ":" + pending.signature;
+ if (checks.current.has(checkId)) return;
+ checks.current.add(checkId);
+ try {
+ const result = await reconcileTransaction(
+ connection,
+ pending.signature,
+ pending.lastValidBlockHeight,
+ );
+ if (!live()) return;
+ if (["finalized", "failed", "expired"].includes(result.status)) {
+ if (!navigator.locks) throw new Error("Web Locks unavailable");
+ await navigator.locks.request(key, { ifAvailable: true }, (lock) => {
+ if (!lock || !live()) return;
+ const removed = clearJournalIfCurrent(localStorage, key, pending);
+ if (!removed && localStorage.getItem(key)) {
+ restore();
+ return;
+ }
+ setRecovery((old) =>
+ old &&
+ sameActionContext(old.context, context) &&
+ samePendingAction(old.pending, pending)
+ ? { ...old, pending: null, error: "" }
+ : old,
+ );
+ setError("");
+ setMessage(
+ result.status === "finalized"
+ ? pending.label + " finalized. Balances are being refreshed."
+ : result.status === "failed"
+ ? "Transaction failed on-chain. No pool action completed; network fees may still apply."
+ : "The transaction expired without a recorded landing. Review a fresh transaction if you still want to proceed.",
+ );
+ settled.current(pending.pool);
+ });
+ } else
+ setMessage(
+ result.status === "confirmed"
+ ? "Confirmed. Waiting for finality before allowing another transaction."
+ : "Waiting for confirmation. Do not submit a replacement.",
+ );
+ } catch {
+ if (live())
+ setMessage(
+ "Confirmation is unavailable. Your transaction may still land. Keep this signature and check again.",
+ );
+ } finally {
+ checks.current.delete(checkId);
+ }
+ }, [
+ pending,
+ address,
+ live,
+ key,
+ connection,
+ restore,
+ context,
+ setError,
+ setMessage,
+ ]);
+ useEffect(() => {
+ if (!pending) return;
+ const poll = () => {
+ if (!document.hidden) void check();
+ };
+ const timer = setTimeout(poll, 0);
+ const interval = setInterval(poll, 4_000);
+ return () => {
+ clearTimeout(timer);
+ clearInterval(interval);
+ };
+ }, [pending, check]);
+
+ async function prepare(
+ label: string,
+ instructions: TransactionInstruction[],
+ lines: [string, string][],
+ pool: string | null,
+ ) {
+ if (
+ gate.current ||
+ pending ||
+ !restored ||
+ !sameReviewScope(scope, currentScope.current)
+ )
+ return;
+ if (!address || !live()) {
+ setError("Connect your current Solana wallet first.");
+ return;
+ }
+ gate.current = true;
+ setBusy(true);
+ setError("");
+ setReview(null);
+ setMessage("");
+ try {
+ if (localStorage.getItem(key)) {
+ restore();
+ throw new Error(
+ "Resolve the wallet's previous transaction before preparing another.",
+ );
+ }
+ await verifyImmutableProgram(connection, new PublicKey(config.programId));
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed. Review the current selection again.",
+ );
+ const tx = await prepareTransaction(
+ connection,
+ new PublicKey(address),
+ instructions,
+ );
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed. Review the current selection again.",
+ );
+ await simulatePreparedTransaction(connection, tx);
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed. Review the current selection again.",
+ );
+ const fee = await connection.getFeeForMessage(
+ tx.transaction.compileMessage(),
+ "confirmed",
+ );
+ if (
+ fee.value === null ||
+ !Number.isSafeInteger(fee.value) ||
+ fee.value < 0
+ )
+ throw new Error("Could not estimate the transaction fee. Try again.");
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed. Review the current selection again.",
+ );
+ if (localStorage.getItem(key)) {
+ restore();
+ throw new Error(
+ "Another tab submitted a transaction. Resolve it before continuing.",
+ );
+ }
+ setReview({
+ context,
+ scope,
+ value: {
+ label,
+ lines,
+ prepared: tx,
+ payer: address,
+ pool,
+ networkFee: fee.value,
+ },
+ });
+ } catch (e) {
+ if (sameReviewScope(scope, currentScope.current))
+ setError(
+ e instanceof Error
+ ? e.message
+ : "Could not simulate this transaction.",
+ );
+ } finally {
+ gate.current = false;
+ setBusy(false);
+ }
+ }
+
+ async function confirm() {
+ if (gate.current || !review || pending || !restored || !reviewLive())
+ return;
+ gate.current = true;
+ setBusy(true);
+ setError("");
+ try {
+ if (!navigator.locks)
+ throw new Error(
+ "This browser cannot safely coordinate wallet submissions across tabs. Use a browser with Web Locks support.",
+ );
+ await navigator.locks.request(
+ key,
+ { ifAvailable: true },
+ async (lock) => {
+ if (!lock)
+ throw new Error(
+ "Another tab is using this wallet. Finish that transaction first.",
+ );
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed. Review the current selection again.",
+ );
+ if (localStorage.getItem(key)) {
+ restore();
+ throw new Error(
+ "This wallet has an unresolved transaction. Check its status first.",
+ );
+ }
+ if (review.payer !== address || !reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed. Review again.",
+ );
+ if (
+ (await connection.getBlockHeight("confirmed")) >
+ review.prepared.lastValidBlockHeight
+ )
+ throw new Error("This review expired. Refresh it before signing.");
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed. Nothing was signed or broadcast.",
+ );
+ await simulatePreparedTransaction(connection, review.prepared);
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed during simulation. Nothing was signed or broadcast.",
+ );
+ const signedBytes = await sign(review.prepared.wireBytes);
+ // No await between this guard, durable journal write and initiating
+ // broadcast. Wallet events invalidate synchronously, before React renders.
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed while signing. Nothing was broadcast.",
+ );
+ const signed = validateSignedTransaction(
+ review.prepared.messageBytes,
+ signedBytes,
+ new PublicKey(address),
+ );
+ const record: PendingSolanaAction = {
+ version: 1,
+ programId: config.programId,
+ cluster: config.cluster,
+ wallet: address,
+ signature: signed.signature,
+ lastValidBlockHeight: review.prepared.lastValidBlockHeight,
+ label: review.label,
+ pool: review.pool,
+ };
+ persistJournalForSession(localStorage, key, record, reviewLive);
+ setRecovery({ context, pending: record, ready: true, error: "" });
+ setReview(null);
+ const result = await submitSignedTransaction(connection, signed);
+ if (live())
+ setMessage(
+ result.status === "unknown"
+ ? "Submission is uncertain. We are checking the signed transaction; do not send another."
+ : "Submitted. Waiting for confirmation.",
+ );
+ },
+ );
+ } catch (e) {
+ if (sameReviewScope(scope, currentScope.current))
+ setError(e instanceof Error ? e.message : "Signing did not complete.");
+ } finally {
+ gate.current = false;
+ setBusy(false);
+ }
+ }
+ return {
+ review,
+ pending,
+ busy,
+ restored,
+ message,
+ error,
+ prepare,
+ confirm,
+ check,
+ cancelReview: () => setReview(null),
+ setError,
+ };
+}
diff --git a/app/src/lib/solana/wallet.ts b/app/src/lib/solana/wallet.ts
new file mode 100644
index 00000000..2a1483d3
--- /dev/null
+++ b/app/src/lib/solana/wallet.ts
@@ -0,0 +1,182 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getWallets } from "@wallet-standard/app";
+import type { Wallet, WalletAccount } from "@wallet-standard/base";
+import type {
+ StandardConnectFeature,
+ StandardDisconnectFeature,
+ StandardEventsFeature,
+} from "@wallet-standard/features";
+import type { SolanaSignTransactionFeature } from "@solana/wallet-standard-features";
+import { PublicKey } from "@solana/web3.js";
+import type { SolanaCluster } from "./config";
+
+type CompatibleWallet = Wallet & {
+ features: StandardConnectFeature &
+ StandardEventsFeature &
+ SolanaSignTransactionFeature &
+ Partial;
+};
+export type WalletSession = {
+ wallet: CompatibleWallet;
+ account: WalletAccount;
+ chain: "solana:devnet" | "solana:mainnet";
+ isCurrent: () => boolean;
+};
+
+function compatible(wallet: Wallet, chain: string): wallet is CompatibleWallet {
+ try {
+ const f = wallet.features as Partial;
+ return (
+ Array.isArray(wallet.accounts) &&
+ wallet.chains.includes(chain as `${string}:${string}`) &&
+ typeof f["standard:connect"]?.connect === "function" &&
+ typeof f["standard:events"]?.on === "function" &&
+ typeof f["solana:signTransaction"]?.signTransaction === "function" &&
+ f["solana:signTransaction"].supportedTransactionVersions.includes(
+ "legacy",
+ )
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function validWalletAccount(
+ account: WalletAccount,
+ chain: string,
+): boolean {
+ try {
+ return (
+ account.chains.includes(chain as `${string}:${string}`) &&
+ account.features.includes("solana:signTransaction") &&
+ new PublicKey(account.publicKey).toBase58() === account.address
+ );
+ } catch {
+ return false;
+ }
+}
+
+/** Independent from wagmi; an EVM wallet/address can never sign a Solana action. */
+export function useSolanaWallet(cluster: SolanaCluster) {
+ const chain = cluster === "devnet" ? "solana:devnet" : "solana:mainnet";
+ const [wallets, setWallets] = useState([]);
+ const [session, setSession] = useState(null);
+ const current = useRef(null);
+ const generation = useRef(0);
+ const stopEvents = useRef<(() => void) | null>(null);
+ const clearConnection = useCallback(() => {
+ generation.current++;
+ current.current = null;
+ const stop = stopEvents.current;
+ stopEvents.current = null;
+ stop?.();
+ }, []);
+ const invalidate = useCallback(() => {
+ clearConnection();
+ setSession(null);
+ }, [clearConnection]);
+ useEffect(() => {
+ const registry = getWallets();
+ const update = () => {
+ const available = registry
+ .get()
+ .filter((w): w is CompatibleWallet => compatible(w, chain));
+ if (current.current && !available.includes(current.current.wallet))
+ invalidate();
+ setWallets(available);
+ };
+ const timer = setTimeout(update, 0);
+ const off = [
+ registry.on("register", update),
+ registry.on("unregister", update),
+ ];
+ return () => {
+ clearTimeout(timer);
+ off.forEach((stop) => stop());
+ clearConnection();
+ };
+ }, [chain, invalidate, clearConnection]);
+ const connect = useCallback(
+ async (wallet: CompatibleWallet) => {
+ invalidate();
+ if (!compatible(wallet, chain))
+ throw new Error(
+ "This wallet no longer supports the selected Solana network.",
+ );
+ const id = ++generation.current;
+ const { accounts } = await wallet.features["standard:connect"].connect();
+ if (id !== generation.current)
+ throw new Error("Wallet changed. Connect again.");
+ const account = accounts.find(
+ (a) =>
+ validWalletAccount(a, chain) &&
+ wallet.accounts.some(
+ (live) =>
+ live.address === a.address && validWalletAccount(live, chain),
+ ),
+ );
+ if (!account)
+ throw new Error(
+ `This wallet has no supported ${cluster} signing account.`,
+ );
+ const next: WalletSession = {
+ wallet,
+ account,
+ chain,
+ isCurrent: () =>
+ id === generation.current &&
+ current.current === next &&
+ compatible(wallet, chain) &&
+ wallet.accounts.some(
+ (live) =>
+ live.address === account.address &&
+ validWalletAccount(live, chain),
+ ),
+ };
+ // Subscribe immediately, before publishing the session. A passive React
+ // effect leaves a gap where account changes can be missed before signing.
+ stopEvents.current = wallet.features["standard:events"].on(
+ "change",
+ invalidate,
+ );
+ current.current = next;
+ setSession(next);
+ },
+ [chain, cluster, invalidate],
+ );
+ const disconnect = useCallback(async () => {
+ const old = current.current;
+ invalidate();
+ await old?.wallet.features["standard:disconnect"]?.disconnect();
+ }, [invalidate]);
+ const sign = useCallback(
+ async (transaction: Uint8Array) => {
+ const active = current.current;
+ const id = generation.current;
+ if (!active || active.chain !== chain || !active.isCurrent())
+ throw new Error("Connect your Solana wallet first.");
+ const result = await active.wallet.features[
+ "solana:signTransaction"
+ ].signTransaction({ account: active.account, chain, transaction });
+ if (
+ id !== generation.current ||
+ current.current !== active ||
+ !active.isCurrent()
+ )
+ throw new Error("Wallet changed while signing. Nothing was broadcast.");
+ if (result.length !== 1 || !result[0]?.signedTransaction)
+ throw new Error("Wallet did not return a signed transaction.");
+ return result[0].signedTransaction;
+ },
+ [chain],
+ );
+ return {
+ wallets,
+ session: session?.chain === chain ? session : null,
+ connect,
+ disconnect,
+ sign,
+ };
+}
diff --git a/docs/SOLANA_IMMUTABLE_POOL_PLAN.md b/docs/SOLANA_IMMUTABLE_POOL_PLAN.md
new file mode 100644
index 00000000..c609bc7c
--- /dev/null
+++ b/docs/SOLANA_IMMUTABLE_POOL_PLAN.md
@@ -0,0 +1,247 @@
+# Immutable Solana launch pools
+
+Status: specification with an implemented review candidate. Not audited, deployed, or approved for mainnet. See [implementation and release handoff](SOLANA_RELEASE.md).
+
+Date: 10 October 2026. Repository baseline: `ef8b642b258ec6dd385335fb24ef00a923c72b62`.
+
+Supersedes [the Meteora integration proposal](SOLANA_MAINNET_PLAN.md). The chosen direction is an original, narrowly scoped pool program rather than a wrapper around an upgradeable DEX. This is protocol development and requires independent economic and security review.
+
+## 1. Product contract
+
+A creator launches a fresh token directly into a token/SOL market. All initial supply enters the pool. Buyers bring real SOL; the creator does not seed quote liquidity. Trading starts immediately after successful activation, without graduation or migration.
+
+Recommended first-release defaults, subject to specification approval:
+
+| Item | Proposed v1 rule |
+| --- | --- |
+| Launch token | Fresh legacy SPL mint created by this program; no existing-token imports |
+| Supply | 1 billion tokens, 6 decimals; `1_000_000_000_000_000` token atoms |
+| Quote | Native SOL, accounted in lamports |
+| Initial price | Creator selects a bounded starting valuation in SOL; its virtual-reserve parameter is fixed forever |
+| Openlaunch cut | Zero launch, platform, referral, and protocol fees |
+| Trading fee | Creator chooses 0%, 1%, or 3%, fixed at activation |
+| Fee destination | For nonzero fees, 1 to 7 unique fixed recipients; shares sum to 10,000 basis points |
+| Fee collection | Accrues separately in SOL; anyone may trigger payment to the fixed recipient |
+| Liquidity | No LP token and no liquidity-withdrawal instruction |
+| Mint controls | No freeze authority; mint authority revoked at activation |
+| Upgrade/admin controls | No active-pool administrative powers; deployed program immutable before activation |
+
+No SOL-fee burn mode. Zero-fee launches need no beneficiaries. For nonzero fees, every accrued trading-fee lamport belongs to beneficiary entitlements or bounded rounding residue, not Openlaunch. Network fees, priority fees, account rent, and storage costs remain separate costs paid by the creator/trader.
+
+Keep Base, Robinhood, and Arc execution unchanged and retain the current UI. This is a new execution backend, not a site redesign.
+
+## 2. Precisely define the guarantee
+
+The target is **no application-controlled administrative path over active pool funds or trading terms**:
+
+- No owner, treasury switch, pause/unpause, fee setter, recipient setter, whitelist, or operator role.
+- No reserve withdrawal, rescue/sweep, migration, arbitrary CPI, active-account close, shrinking realloc, or authority/delegate escape route.
+- No upgrade authority after release. New code versions use new program IDs for future launches; they cannot rewrite existing pools.
+- Ordinary trades exchange reserves according to fixed rules. Locked liquidity does not mean tokens can never leave the vault.
+
+This does not promise profit, a redemption floor, exploit immunity, availability of the website/RPC, or immunity from Solana runtime/consensus changes. An immutable bug cannot be patched in place. Disabling our frontend cannot stop direct on-chain trading or an exploit.
+
+Use native SOL and the original SPL Token Program to avoid an external AMM in the custody path. A read-only mainnet check on 10 October at finalized slot `455205523` found the legacy Token Program `TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA` had no upgrade authority. ProgramData was `3gvYRKWyXRR9xKWe1ZjPhLY5ZJRN7KDB4rFZFGoJfFk2`, deployment slot `419472000`. Verify again before release; loader ownership alone is insufficient to determine upgradeability. [Token documentation](https://solana.com/docs/tokens)
+
+## 3. Pricing model and solvency
+
+Use an offset constant-product model, a known single-sided-sale construction. SPL Token Swap documents this model, but its archived implementation is a reference, not an inherited audit or a ready-made fee/locking system. Any reused code requires commit-pinned license and provenance review. [Offset-curve reference](https://github.com/solana-labs/solana-program-library/blob/master/docs/src/token-swap.md#offset), [Apache-2.0 license](https://github.com/solana-labs/solana-program-library/blob/master/LICENSE)
+
+All calculations use checked integers. Define:
+
+- `S`: original supply in token atoms.
+- `X`: accounted token inventory in the pool.
+- `R`: accounted real SOL reserve, excluding fees and rent.
+- `V > 0`: immutable virtual SOL offset, in lamports.
+- `f`: selected fee basis points; `B = 10_000`.
+
+Initialize `X = S`, `R = 0`. Preserve:
+
+```text
+0 < X <= S
+R >= 0
+X * (R + V) >= S * V
+```
+
+`V` is not a deposit, collateral, TVL, or spendable balance. At launch, spot-price FDV in SOL equals `V / 1_000_000_000`, while real SOL reserves are zero. The UI must display these as different facts. Starting valuation presets and bounds require economic review; no USD oracle controls the curve.
+
+### Exact-input buy
+
+For gross SOL input `g`:
+
+```text
+fee = ceil(g * f / B)
+net = g - fee
+tokensOut = floor(X * net / (R + V + net))
+
+require net > 0
+require 0 < tokensOut < X
+require tokensOut >= minTokensOut
+
+X' = X - tokensOut
+R' = R + net
+```
+
+The reserve receives `net`; the fee vault receives `fee`. Account creation and transaction fees are not secretly subtracted from the quoted trade amount.
+
+### Exact-input sell
+
+For input `t` token atoms:
+
+```text
+require 0 < t <= S - X
+
+gross = floor((R + V) * t / (X + t))
+fee = ceil(gross * f / B)
+solOut = gross - fee
+
+require gross <= R
+require solOut > 0
+require solOut >= minLamportsOut
+
+X' = X + t
+R' = R - gross
+```
+
+The real reserve pays both `solOut` and `fee`; the fee portion moves to the separate fee vault. Both directions enforce an on-chain expiry slot as well as minimum output.
+
+Why virtual SOL cannot be paid out: the invariant gives `R*X >= V*(S-X)`. A valid sell has `t <= S-X`, so `V*t <= R*X`, which implies `(R+V)*t/(X+t) <= R`. Conservative rounding preserves this bound. Keep the explicit real-reserve check anyway; never hide insolvency by capping a quote to available funds.
+
+Require token amounts, `R+V`, and post-buy `R+V+net` to fit documented `u64` bounds so two-factor products fit `u128`. Use overflow-safe ceiling division. Lifetime cumulative fees and paid counters use separately bounded `u128` accounting, not the `u64` reserve limit; entitlement multiplication needs overflow-safe decomposition or a reviewed wider intermediate. No floating-point prices in the program or quote builder.
+
+For illustration only, with 1 billion tokens, `V = 30 SOL`, and a 1% fee: a 1 SOL buy receives `31,945,788.964181` tokens. Immediately selling those tokens back, without other trades, returns `0.980099999 SOL`, excluding network costs. One lamport remains in real reserves through rounding. The virtual 30 SOL was never withdrawable. This is not a proposed default valuation or a return forecast.
+
+The final token atom cannot be bought at finite input. Full round-trip reversal can leave permanently locked rounding dust. Profitable trades after market movements remain possible; only isolated round trips should be non-profitable before external incentives.
+
+## 4. Separate custody and fee accounting
+
+```text
+Pool state: immutable terms + accounted reserves + cumulative fee ledger
+ |-- Token vault: launch inventory, exact SPL mint, pool PDA authority
+ |-- SOL reserve vault: real trading reserves + protected rent
+ `-- SOL fee vault: beneficiary obligations + protected rent
+```
+
+Each launch has independent PDA-derived accounts. No global fund vault or global writable configuration. Program-derived addresses remove private-key custody, but correctness still depends on the instructions permitted to sign for them. [PDA documentation](https://solana.com/docs/core/pda)
+
+For cumulative earned fees `F`, recipient weight `w_i`, and cumulative paid amount `paid_i`:
+
+```text
+entitlement_i = floor(F * w_i / 10_000)
+claimable_i = entitlement_i - paid_i
+```
+
+Claims pay only the recipient stored in immutable pool state, never a caller-selected destination. No recipient signature is needed merely to send its earned SOL to that exact address. Reject default, duplicate, or otherwise invalid recipients; validate account compatibility without excluding supported smart-wallet PDAs merely for being off-curve. Individual claims must not depend on other recipients, change `X` or `R`, or touch the reserve vault. Use overflow-safe multiply/divide for the mathematical entitlement formula. Cumulative accounting avoids rounding loss growing with trade count; unallocated residue after all claims is below the recipient count in lamports.
+
+Use accounted balances, not raw vault balances, for prices and claims:
+
+```text
+actual token balance >= X
+actual SOL reserve balance >= protected rent + R
+actual fee vault balance >= protected rent + F - totalPaid
+```
+
+Anyone can send unsolicited assets. Donations must not change prices, virtual reserves, or fee entitlements, and must not brick the pool through equality checks. Donated surplus stays inaccessible in v1: no discretionary recovery instruction. Reject recipient aliases with protocol-owned accounts and duplicate mutable accounts. Reserve and fee movements must satisfy conservation even when a recipient is the creator or trader.
+
+## 5. Minimal instruction surface and launch lifecycle
+
+Proposed instructions:
+
+| Instruction | Authority and effect |
+| --- | --- |
+| `prepare_launch` | Creator signs; binds nonce, terms, metadata commitment, recipients, and original rent payer. No tradable inventory. |
+| `activate_launch` | Creator signs; validates preparation and immutability, creates/funds token inventory, revokes mint authority, and atomically marks Active. |
+| `cancel_preparation` | Original creator only, never after activation; closes only eligible empty preparation accounts and returns permitted rent to its recorded payer. |
+| `buy_exact_in` | Trader-authorized input; audited quote, minimum output, expiry, reserve and fee updates. |
+| `sell_exact_in` | Trader-authorized token input; same controls and explicit real-reserve solvency. |
+| `claim_fees` | Permissionless trigger; payout only to a fixed recipient from the fee vault. |
+
+```text
+Uninitialized -> Prepared -> Active (permanent)
+ `----> Cancelled (terminal)
+```
+
+Prefer preparing configuration only, then creating the mint and token vault during activation. All supply must be minted to the vault and mint authority revoked in the same successful activation. No freeze authority from initialization; no creator allocation or token delegate. Emit an activation event only after verifying postconditions.
+
+Measure packet size and compute for the actual maximum-input transaction, including any metadata instructions. Standard legacy/v0 transactions have a 1,232-byte packet budget; do not promise everything fits one transaction. [Transaction limits](https://solana.com/docs/core/transactions)
+
+If account creation must be staged, a prepared mint must have zero supply and only a launch-scoped mint-authority PDA as mint authority. Failed or abandoned preparation cannot create a trading market. Retain a small permanent Cancelled tombstone so closing auxiliary accounts cannot permit nonce reuse; only eligible auxiliary rent can be refunded. Legacy mint rent and tombstone rent may be unrecoverable on cancellation; disclose this rather than promising a full refund. Prevent reinitialization, cancellation/activation races, and prefunded-PDA denial of service. Do not rely on an ephemeral browser key for recovery.
+
+### Immutability before activation
+
+`activate_launch` must reject while this program retains an upgrade authority. Verify the executable account equals this program ID, the expected loader, its recorded ProgramData address, ProgramData ownership/layout, and an absent authority. An unrelated immutable ProgramData account must not satisfy the check.
+
+Use disposable immutable program IDs for devnet lifecycle tests. Production sequence: review exact source, build reproducibly, deploy, verify binary/source match, revoke upgrade authority, independently verify revocation, then permit activation. Revocation itself requires explicit deployment approval. [Deployment rules](https://solana.com/docs/core/programs/program-deployment), [verifiable builds](https://www.anchor-lang.com/docs/references/verifiable-builds)
+
+### Metadata is separate from money
+
+Store canonical bounded name, symbol, URI/content hash in our immutable state. Images need a durable hosting policy; a content hash does not guarantee availability. Editable profile/social content remains off-chain and cannot change pool terms.
+
+A standard wallet-readable metadata mirror is a compatibility feature to prove during feasibility, not an authority over custody. If a Metaplex mirror is used, isolate setup before initial supply minting using a distinct temporary mint-authority PDA and bounded setup funding, not reserve/fee authority or unrestricted payer signing powers. Recheck zero supply, mint/freeze authorities and vault owner/delegate/close-authority state after that CPI, then mint and revoke atomically. No external metadata CPI during swaps or claims. Finalize intended display mutability; trading and claims cannot depend on metadata service availability. If this isolation cannot be proven, omit the mirror rather than weaken custody. Do not promise every wallet recognizes our custom metadata automatically.
+
+## 6. Complete mainnet app integration
+
+The mainnet release must include launch, native buy/sell, fee claims, discovery, token pages, watchlists, wallet balances, creator profiles/posts, and transaction recovery. No demo-only success states or hardcoded production data.
+
+- **Wallets:** Wallet Standard discovery and signing, verified supported account/cluster/features, desktop/mobile matrix, account-change invalidation, simulation, fees/rent review, and validation of wallet-returned signed messages. No private-key input or server user signer.
+- **SDK:** Deterministic PDA derivation, account decoding, exact integer quotes, typed instruction builders and public integration documentation. Program, SDK and independently written reference model must agree. Unknown submission results must be reconciled before any retry with a new transaction.
+- **Storage/auth:** Family-aware identities and case-sensitive base58. Keep existing EVM keys/URLs stable. Add separate Ed25519 auth with domain, action, network, address, nonce and expiry binding; do not reuse EVM lowercase/signature middleware.
+- **Indexer:** Durable slot/signature/instruction cursors, idempotent backfill, failed-transaction filtering, inner-CPI attribution, per-pool sequence verification and confirmed/finalized reconciliation. Verify event origin, not just its text.
+- **Charts:** First-party candles from real successful swaps. Define pricing/volume consistently from the curve-traded quote amount, separately recording fees and gross wallet amounts. Show no-trades/stale states; do not fabricate history from starting valuation. Display actual SOL reserves, virtual offset and FDV separately.
+- **Holders:** Ingest ordinary SPL transfers outside our program and reconcile token accounts by wallet. A swap-only indexer is insufficient. Withhold metrics whose coverage is not proven.
+- **Availability:** Publish a minimal SDK/CLI recovery path for trading and claims without our hosted UI. Frontend flags control our interface, not on-chain access.
+
+Jupiter routing, wallet swap support, GeckoTerminal and DEX Screener listings are separate integration/adoption work. A new program does not inherit these through deployment. External chart coverage must not be a release dependency.
+
+Out of scope for v1: USDC pairs, bridged assets, existing-token imports, Token-2022 extensions, LP deposits, liquidity migration, graduation, anti-sniper taxes, dynamic fees, buybacks, price oracles in the swap path, cross-wallet identity linking, Solana bridge routes, and points rewards without a separate scoring review.
+
+## 7. Implementation boundaries and milestones
+
+Proposed boundaries, not directories to create before they are needed:
+
+```text
+solana/programs/launch_pool/ Original Rust program
+solana/tests/ Models, adversarial tests, lifecycle fixtures
+solana/deployments/ Build hashes, program IDs, authority evidence
+packages/solana-sdk/ Decoders, integer quotes, builders, generated types
+app/src/lib/solana/ Wallet, execution, data adapter, indexer, auth
+app/db/ Additive migrations; EVM storage unchanged
+```
+
+Prefer a pinned Rust/Anchor toolchain for explicit account constraints and reproducible builds, subject to feasibility and reviewer support. Do not add a generalized multi-chain framework or fork an entire DEX UI.
+
+1. **Specification and independent model:** freeze economics, account map, bounds, state machine, cost assumptions and threat model. Independently review the solvency proof and compare reference implementations. Exit before UI polish: every movement of SOL and tokens is explainable and conserved.
+2. **Program and SDK:** implement the minimal surface, property/differential/adversarial tests, measured transaction limits, and immutable disposable test deployments. No mainnet activation.
+3. **Complete testnet product:** connect real wallets, indexing, charts, discovery, profiles, trading and claims. Demonstrate rejection, reload, RPC outage, stale quotes and insufficient funds without duplicate transactions.
+4. **Independent audit and release rehearsal:** audit the exact program and economic model, fix findings, repeat regression/fuzz tests, verify builds and dependency authorities, rehearse immutable release and incident response.
+5. **Explicitly authorized mainnet canary:** use a separately approved limited test budget on the final immutable deployment. Verify launch, buy, sell, claim and indexer recovery before enabling creation in our production UI.
+
+Once the immutable permissionless deployment exists, others can activate pools directly. The canary budget caps only our testing, not protocol TVL or public on-chain access. Do not add an admin allowlist or activation switch to imply otherwise; independent review must precede that deployment.
+
+Use one coherent draft feature PR with staged, reviewable commits and an honest checklist. Testnet code may be reviewed before an audit, but must not be presented as mainnet-ready. Production activation remains blocked by the release gates, not by a promise to fix things later.
+
+## 8. Required acceptance tests
+
+- Randomized buys/sells/claims preserve token conservation, real-reserve solvency, nondecreasing product, and fee liabilities.
+- Isolated buy/sell and sell/buy round trips cannot create value; repeated tiny trades and extreme boundaries cannot extract rounding value.
+- Selling all circulating tokens never spends virtual reserves or fee funds. User burns and donations do not invalidate accounting.
+- Maximum inputs, fee ceilings, zero-output trades, minimum output, expiry, near-empty inventory and lifetime counter bounds behave identically in program and SDK.
+- Claims are solvent and order-independent, repeated claims cannot overpay, and one unusable recipient cannot block others.
+- Wrong owners/program IDs, account substitution, PDA bump/seed collisions, aliases, unexpected delegates/close authorities, prefunding and replay cannot bypass controls.
+- Activation atomically mints the full supply, removes mint authority, verifies custody and becomes irreversible. Cancellation cannot release active funds or resurrect a cancelled launch.
+- Wrong ProgramData, retained upgrade authority, active account closure/reallocation, arbitrary CPI and extra mint/freeze attempts fail.
+- Index rebuild matches on-chain reserves, fee entitlements, trades and holders, including ordinary transfers, failed transactions, multiple swaps and inner CPIs.
+- Desktop/mobile wallet recovery passes; EVM tests, lint, TypeScript, production build and Base/Robinhood/Arc regressions remain green.
+
+The initial planning sanity check has been replaced by persisted regression suites: an independent BigInt model, SDK differential tests, Rust host tests, and compiled-SBF offline VM execution. Their coverage and limitations are recorded in [the build handoff](../solana/BUILD.md) and [release checklist](SOLANA_RELEASE.md). Passing automated checks is not an exhaustive proof or independent audit.
+
+## 9. Decisions and release evidence still required
+
+- Approve fixed 1-billion supply/6 decimals, fixed fee choices, token/SOL-only scope and no SOL-fee burn mode.
+- Select initial valuation bounds/presets through economic review; publish realistic examples of slippage and available exit liquidity.
+- Set metadata/hosting policy, wallet compatibility targets, and maximum name/URI sizes before packet measurements.
+- Assign independent protocol/economic review, deployment custody/revocation procedure, RPC/indexer operations, audit costs and canary budget.
+- Produce a public commit/build/program manifest, exact deployed-bytecode match, absent upgrade-authority evidence, revoked mint/freeze proofs, test reports and closed audit findings.
+- Rehearse incident response without an on-chain pause or rescue. A frontend rollback cannot recover funds from flawed immutable code; publish notices and preserve verified recovery interfaces.
+
+The review candidate now includes the original pool program, SDK, independent model, isolated app workspace, read-only release verifier and CI. No program has been deployed, no on-chain authority has been revoked, and no funded wallet transaction has been performed. Public launch remains blocked until the release checklist is satisfied.
diff --git a/docs/SOLANA_MAINNET_PLAN.md b/docs/SOLANA_MAINNET_PLAN.md
new file mode 100644
index 00000000..bffe1eba
--- /dev/null
+++ b/docs/SOLANA_MAINNET_PLAN.md
@@ -0,0 +1,161 @@
+# Native Solana mainnet integration
+
+Superseded on 10 October 2026 by [the original immutable launch-pool plan](SOLANA_IMMUTABLE_POOL_PLAN.md). This document is retained as the evaluated Meteora option, not the active implementation direction.
+
+Status: proposed architecture, not approved for deployment. Researched 10 October 2026 against Openlaunch main `ef8b642b258ec6dd385335fb24ef00a923c72b62`.
+
+## Recommendation
+
+Build an Openlaunch-owned Solana integration on Meteora's deployed DAMM v2. Use direct token/SOL pools, permanent locking of launch liquidity, and a small original Openlaunch program for launch registration and fixed fee rights. Do not build another AMM or buy a vendor-dependent launch stack.
+
+This is a complete mainnet product plan, not a devnet-only launch demo. Devnet is a verification stage. Mainnet release includes launch, discovery, buy, sell, charts, holders, fee claims, wallet identity, and transaction recovery.
+
+Meteora is a strong functional fit for the existing immediate-pool model, not an unconditional match for Openlaunch's trust guarantees. Its single-sided pool creation does not require the creator to seed quote liquidity. Bonding-curve graduation would be a different product and is not part of this proposal. [Pool creation documentation](https://docs.meteora.ag/user-guides/creating-a-liquidity-pool)
+
+## 1. Decisions that must precede implementation
+
+### External program trust
+
+Openlaunch's [contribution rules](../CONTRIBUTING.md) reject contract owners, pause controls, and upgrade paths. Our own Solana program should preserve that policy: no platform fee, no privileged withdrawal, no recipient editor, no pause instruction, and no retained upgrade authority on the production deployment.
+
+That cannot make Meteora immutable. A read-only mainnet RPC inspection on 10 October, at context slots `455201469` and `455201470`, found:
+
+| Program | Address | Observed deployment slot |
+| --- | --- | --- |
+| DAMM v2 | `cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG` | `445230614` |
+| Dynamic Fee Sharing | `dfsdo2UqvwfN8DuUVrMRNfQe11VaiNoKcMqLHVvDPzh` | `435662190` |
+
+Both retained upgrade authority `JADaUV8kvDpDbJr55wxXJHVaBS3VCj8thZZHjfeuCVLd`. DAMM v2 ProgramData was `AUh8bm2XsMfex3KjYGcM3G4uBqUNSDw6HEhWaWMYnyPH`. This is an observation, not an audit: authority ownership, signing policy, timelock, and a deployed-binary/source match remain unverified. Repeat and preserve this evidence before release.
+
+At source commit `a85c926607433f23f0ea60f4ca7b1ae92f4156cb`, authorized protocol operators can change pool status and eligible fee parameters. The account checks do not exclude the permissionless customizable pools proposed here or require creator consent. This source-level finding is distinct from proving that source matches the deployed mainnet binary. Do not promise an unpausable pool or permanently fixed trading fees. [Status handler](https://github.com/MeteoraAg/damm-v2/blob/a85c926607433f23f0ea60f4ca7b1ae92f4156cb/programs/cp-amm/src/instructions/operator/ix_set_pool_status.rs#L12-L38), [fee handler](https://github.com/MeteoraAg/damm-v2/blob/a85c926607433f23f0ea60f4ca7b1ae92f4156cb/programs/cp-amm/src/instructions/operator/ix_update_pool_fees.rs#L104-L132), [operator authorization](https://github.com/MeteoraAg/damm-v2/blob/a85c926607433f23f0ea60f4ca7b1ae92f4156cb/programs/cp-amm/src/access_control.rs#L12-L24)
+
+Required decision: accept this explicitly disclosed third-party trust boundary, or stop and evaluate an immutable alternative. Do not copy the EVM claim of an entirely non-upgradeable launch path onto Solana.
+
+### Fees and supply claims
+
+Proposed policy:
+
+- Openlaunch launch/platform/referral cut: zero.
+- Initial selectable total trading fees: 0.01%, 1%, or 3%, subject to verified pool constraints. No 0% choice.
+- Use quote-only fee collection (`OnlyB`) with dynamic fees and fee scheduling disabled initially.
+- Display total trading fee, Meteora protocol deduction, and beneficiary LP share separately. Meteora currently documents a default protocol share of 20% of trading fees. For example, a 1% trade fee at that split allocates 0.2% of trade value to protocol and 0.8% to LP beneficiaries, before rounding. Recheck actual on-chain parameters; do not hardcode the split as a permanent guarantee. [Fee documentation](https://docs.meteora.ag/core-products/damm-v2/fees/overview)
+- No SOL-fee burn mode in the first release. Burning wrapped SOL through the Token Program is unsupported; unwrapping is not burning. A forfeiture vault or buyback-and-burn would need a separate specification. [Solana burn semantics](https://solana.com/docs/tokens/basics/burn-tokens)
+- Creation costs include account rent, network fees, and any required setup funding. Show an estimate before signing; do not say the only cost is gas.
+
+Locked liquidity is not backing, a price floor, or guaranteed exit value. In a single-sided launch, buyers bring quote reserves. Show real reserves separately from market capitalization, and explain that selling depends on available reserves and price impact.
+
+## 2. Mainnet release scope
+
+| Area | Required outcome |
+| --- | --- |
+| Wallets | Solana Wallet Standard discovery, account changes, disconnect, supported-network checks, and mobile-wallet verification. Keep existing EVM connections intact. |
+| Launch | Name, symbol, image, immutable launch terms, validated supply and starting-price range, fee recipients, cost review, simulation, submission, confirmation, recovery. |
+| Pool | Direct token/SOL DAMM v2 pool; all launch liquidity permanently locked under the selected program's rules; no creator inventory allocation. |
+| Trading | Native buy and sell, current quotes, minimum received, price impact, slippage protection, network/account costs, SOL gas reserve, and explicit failed/pending states. |
+| Discovery | Solana chain filter, case-sensitive mint lookup, launch list, watchlist, token page, creator attribution, activity, and explorer links. |
+| Market data | Real trades, correctly scaled prices, market cap, reserves, volume, wallet-level holders, and locally indexed OHLC. External chart indexing must not gate a new token's chart. |
+| Fees | Fixed recipients, permissionless fee collection, individual claim accounting, claim history, and a usable fee dashboard. |
+| Identity | Solana message-signature authentication for profiles and posts, chain-aware creator permissions, and existing moderation rules. |
+| Operations | Durable indexer/backfill, error monitoring, recovery documentation, provider capacity, reproducible release, and EVM regression coverage. |
+
+Keep the current Openlaunch visual system and layouts. Add chain-specific explanations where guarantees differ, not a second website or a visual remake. Reuse existing controls and status patterns; label preparation, awaiting signature, submitted, confirmed, finalized, expired, and recoverable failure distinctly.
+
+Initial mainnet support is token/SOL only. USDC pairs, Solana bridging, bonding curves, multiple AMMs, arbitrary Token-2022 extensions, buybacks, and linked EVM/Solana identities are separate follow-ups. Solana actions remain outside points scoring until attribution and abuse rules have their own approved tests; disclose that exclusion instead of implying rewards.
+
+## 3. Architecture
+
+### Small original program, existing AMM
+
+Use an original Rust program, with a pinned framework/toolchain selected during the feasibility stage. Its responsibilities are narrowly limited to:
+
+1. Bind creator, mint, pool, position, launch terms, and fee recipients in a deterministic on-chain launch record.
+2. Keep preparatory inventory under program control until launch finalization.
+3. Verify mint, supply, pool configuration, full liquidity lock, and position-NFT custody before marking a launch active.
+4. Hold position fee rights in a program-derived account with no transfer, approval, liquidity-removal, or arbitrary-call escape route.
+5. Collect only fees and credit immutable recipient shares, with no platform destination.
+
+Recommend one to seven unique recipients, matching the current EVM upper bound, with nonzero shares totaling 10,000 basis points. If transaction constraints require a smaller bound, revise the specification explicitly before implementation. Use deterministic integer accounting, retained dust, and independent claims so one recipient cannot block another. No changeable payout destination or whole-vault sweep.
+
+Meteora's existing Dynamic Fee Sharing is a lower-code alternative, but it supports two to five recipient entries and shareholder-authorized integration funding. It does not directly preserve our single-beneficiary, permissionless-collection product. Do not create duplicate recipients to work around its minimum. [DFS design](https://docs.meteora.ag/helper-products/dynamic-fee-sharing/design-guide)
+
+The original wrapper adds audit and deployment work. If fixed fee rights and on-chain registration are not requirements, a direct SDK integration could be smaller, but that is a different product contract and needs an explicit decision.
+
+### Token and metadata
+
+Prefer standard SPL Token initially. Choose and test supply/decimal bounds before exposing them; do not reuse ERC-20's 18-decimal assumptions. At activation, revoke mint authority and leave no freeze authority. Account for the complete minted supply: deposit it or burn documented rounding residue; never silently return residual launch inventory to the creator. Display final supply accurately after any burn.
+
+Use a reviewed standard metadata implementation compatible with existing wallets. Record the initial content hash/URI and disclose metadata-update authority separately from mint authority. Keep editable social/profile information outside immutable financial terms. Metadata storage and availability need an explicit retention policy.
+
+No transfer-tax, transfer-hook, permanent-delegate, default-frozen, or other unreviewed token extensions. A later Token-2022 decision must enumerate allowed extensions and prove pool, wallet, and indexer compatibility.
+
+### Wallet and transaction boundary
+
+Add a Solana-specific client adapter; do not force Solana signing through wagmi or viem. Initialize Wallet Standard in the browser, verify supported accounts/chains/features, and invalidate quotes and prepared transactions on account, network, amount, or route changes. No private-key input, browser-stored seed, or server-held user signer. [Wallet Standard guidance](https://docs.phantom.com/developer-powertools/wallet-standard)
+
+Build and validate expected instructions locally. Pin program IDs, mints, destinations, amount bounds, and fee budgets. A quote or RPC response must not authorize arbitrary instructions. Verify the wallet-returned signed message against the reviewed transaction before broadcasting; reject unsupported mutations rather than assuming the wallet returned identical instructions. Simulate before signing, handle blockhash expiry, and reconcile unknown submission results before offering a fresh transaction. Never equate a returned signature with success.
+
+Research baseline: `@meteora-ag/cp-amm-sdk@1.5.1`, upstream commit `79ebbfe59a225e641a2f37cd03404f26de1b0c8e`. Pin compatible Solana dependencies with a documented reason and lockfile review. The SDK is MIT; the DAMM program source has a separate noncommercial license. Use the supported deployed program interface, not a copied/redeployed AMM implementation. Confirm intended use and any copied code against their respective terms. [SDK](https://github.com/MeteoraAg/damm-v2-sdk/tree/79ebbfe59a225e641a2f37cd03404f26de1b0c8e), [program license](https://github.com/MeteoraAg/damm-v2/blob/main/license.md)
+
+### Transaction boundaries and recovery
+
+The SDK's custom-pool builder can append permanent locking to the pool-creation transaction. That does not establish that mint, metadata, pool, lock, fee custody, and registry fit together. Measure serialized size, signer count, compute, CPI depth, and account limits. Plan against the supported legacy/v0 1,232-byte packet budget; do not depend on newer formats without wallet and cluster verification. [SDK example](https://github.com/MeteoraAg/damm-v2-sdk/blob/79ebbfe59a225e641a2f37cd03404f26de1b0c8e/examples/createCustomizablePoolAndLockLiquidity.ts), [transaction limits](https://solana.com/docs/core/transactions)
+
+If preparation requires multiple transactions, this remains mandatory:
+
+```text
+Prepare mint / metadata / escrow
+ |
+ v
+Finalize atomically: pool + permanent lock + fixed fee custody + launch record
+ |
+ v
+Verify confirmed state -> reconcile finalized state -> expose verified launch
+```
+
+Preparation must not release tradable inventory. Failure must not leave an unlocked live pool. Recovery is derived from on-chain launch state and public identifiers, not an ephemeral browser key. Specify retry and pre-activation cancellation/rent recovery without creating a post-activation withdrawal path. If the atomic finalization invariant cannot be met, stop and redesign rather than weaken it.
+
+### Storage, indexing, and authentication
+
+Existing storage and code assume lowercase EVM addresses, numeric chain/position IDs, ERC-20 transfers, Uniswap ticks, and EVM signatures. Add family-aware identities such as `{ family, network, address }`, preserve case-sensitive Solana base58 strings, and leave existing EVM keys and URLs stable.
+
+Use additive Solana tables and normalized read models rather than rewriting EVM financial math. Store integers exactly, decimals explicitly, and unique events by network/signature/instruction path. A separate ingestion worker needs durable cursors, bounded retries, backfill, idempotency, failed-transaction filtering, and confirmed/finalized reconciliation.
+
+Decode both outer and inner instructions. Attribute each launch pool's swaps at instruction level, including aggregator routes and multi-hop transactions executed outside Openlaunch; do not count route-level transfers again as pool volume. Verify account ownership and launch invariants independently of emitted logs. Aggregate holders by wallet rather than token-account count, distinguish pools/vaults from users, and reconcile balances after restart. Stale or unavailable USD conversion must appear unavailable, not as zero or an invented price.
+
+Add a separate Ed25519 authentication path with domain, action, address, network, nonce, and expiry binding. Consume nonces once. Do not reuse EVM verification or lowercase middleware. Cross-wallet identity linking is deferred, not inferred from a shared browser session.
+
+## 4. Delivery sequence
+
+Use one feature branch and one coherent draft PR with reviewable commits and a checklist. Mainnet remains disabled until all release gates pass.
+
+1. **Architecture acceptance and feasibility:** approve trust and fee differences; prove selected pool permissions, CPI compatibility, transaction bounds, token/metadata strategy, and fee custody. Produce threat model and measured fixtures before committing to the wrapper design.
+2. **On-chain invariants:** implement the small original program and adversarial tests, launch-state recovery, fee accounting, reproducible builds, and deployment scripts that never submit by default.
+3. **Wallet and execution:** build launch, buy, sell, claims, simulations, confirmation, and reload recovery through the real wallet adapter.
+4. **Indexer and product coverage:** add migrations, ingestion, normalized data, token pages/charts, discovery, dashboard, and Solana authentication. Exercise the entire journey with real test-cluster data.
+5. **Security and release:** independent review, full regression suite, operator runbook, and an explicitly authorized capped mainnet canary. Only then enable public mainnet creation.
+
+No mainnet deployment, upgrade-authority revocation, funded test, or transaction signing is authorized by this plan alone. Deployment cost, review cost, and RPC capacity are approval items, not assumed budget.
+
+## 5. Release gates
+
+- Prove no extra minting, freezing, unlocked-liquidity escape, position transfer, recipient mutation, platform diversion, duplicate claim, or cross-launch fee theft.
+- Test substituted/malformed accounts, wrong program owners, PDA seed collisions, duplicate recipients, integer overflow, rounding dust, closed accounts, donations, replay, and unauthorized CPI paths.
+- Complete launch -> buy -> sell -> collect -> claim on local/test infrastructure, including maximum supported inputs, packet/compute measurements, slippage failures, rejected signatures, expired blockhashes, RPC disagreement, and reload at every stage.
+- Rebuild the index from chain data and compare supply, reserves, trades, holders, and claims. Include direct swaps, aggregator CPI/multi-hop routes, multiple swaps in one transaction, and failed transactions; prove intermediate transfers do not inflate trade counts, users, or volume. Verify reorg/finality handling, restart safety, and incomplete-provider responses.
+- Exercise desktop and mobile wallets; validate wallet switching, reconnect, insufficient funds, account rent, and priority-fee caps.
+- Run repository lint, TypeScript, unit tests, production build, Solidity tests, and Base/Robinhood/Arc regression flows. Add dedicated Rust/Solana CI and migration replay tests.
+- Review exact deployed Meteora versions and audit coverage. Independently review our program, transaction builders, and authentication; upstream audits do not cover this integration. [Meteora audit index](https://docs.meteora.ag/resources/audits/damm-v2)
+- Publish our reproducible program build and deployment manifest; verify its mainnet binary/source match and exact authority state. Repeat the canary lifecycle against the final immutable production state before public creation. Immutable bugs require a new version for future launches, not upgrades to existing custody.
+- Monitor upstream ProgramData/deployment, authority, and relevant pool/config changes. Unexpected changes must disable new in-app submissions and invalidate locally prepared unsigned transactions until independently reviewed. This cannot revoke previously signed transactions or pause the external protocol. Keep read access and recovery information available; expose claims only when their execution path remains verified.
+- Document monitoring for indexer lag, quote failures, confirmation backlog, and accounting discrepancies. Establish funded operating capacity and ownership.
+- Rehearse rollback: disable new submissions in the frontend without pretending to pause the chain; keep existing positions, claims, explorer links, and recovery usable. A locked launch cannot be unwound by a UI rollback.
+
+## Approval checklist
+
+- [ ] Accept Meteora's external upgrade/administrative trust boundary and chain-specific disclosures.
+- [ ] Approve token/SOL first, zero Openlaunch cut, explicit protocol fees, no SOL-fee burn mode, and no initial points eligibility.
+- [ ] Approve the original minimal registry/fee-custody program, subject to the atomic-finalization feasibility gate.
+- [ ] Resolve token decimals/supply/range bounds, metadata authority/storage policy, and supported wallet matrix during the specification stage.
+- [ ] Assign independent security review, deployment authority handling, RPC/indexer ownership, and canary budget before mainnet release.
+
+Application code is unchanged at this planning checkpoint. No launch simulation, mainnet transaction, deployment, or security audit is claimed complete.
diff --git a/docs/SOLANA_RELEASE.md b/docs/SOLANA_RELEASE.md
new file mode 100644
index 00000000..0c990828
--- /dev/null
+++ b/docs/SOLANA_RELEASE.md
@@ -0,0 +1,74 @@
+# Solana implementation and release handoff
+
+Status: **review candidate, not a mainnet release**. The workspace stays separate at `/solana`, with `SOLANA_ENABLED=0` and an unapproved, empty deployment manifest. Existing Base, Robinhood and Arc execution, authentication and database schemas are unchanged.
+
+## Implemented
+
+- Original token/SOL pool program: prepare, activate, cancel preparation, buy, sell and claim fixed beneficiary fees. No LP withdrawal, active-pool admin, fee setter, migration or emergency rescue instruction.
+- Fixed 1-billion supply, 6 decimals, legacy SPL Token; activation places the full supply in custody and removes mint authority. Activation rejects a retained program upgrade authority. Trading reserves and beneficiary fees are separate accounts.
+- Exact-integer SDK quotes, account/PDA/authority validation, binary-compatible instruction builders, packet-size checks, simulation, signed-message verification and signature-based finality recovery.
+- Separate Wallet Standard workspace with explicit launch/trade/claim review. Signed transaction identity is journaled before broadcast; uncertain submissions are reconciled, never automatically replaced. Browser storage and Web Locks support are required for signing.
+- Bounded, confirmed-event history and five-minute candles from this program's own swaps. This is a **recent window**, not lifetime volume, a full indexer or an aggregator listing.
+- Feature-gated RPC and history endpoints; pinned Rust/SDK lockfiles, build-only CI and a read-only deployed-bytecode verifier.
+
+## Local evidence
+
+See [BUILD.md](../solana/BUILD.md) for exact toolchain versions, fixture hash, commands, compute observations and limitations. The current fixture passed 21 Rust host tests and 9 compiled-SBPFv3 LiteSVM tests. The SDK and independent economic model have separate regression suites. The app's lint, TypeScript, unit tests and production build are checked separately; CI must also pass for the final PR commit.
+
+Offline VM tests use ephemeral identities and real System/SPL Token processors, not funded network wallets. No public-cluster deployment or authority operation has been performed. The checked-in fixture program ID is deliberately refused by app configuration and the release verifier.
+
+## Reproduce without deploying
+
+From `app/`:
+
+```sh
+npm ci --no-audit --no-fund
+npm run lint
+npx tsc --noEmit -p .
+npm test
+npx tsc --noEmit -p packages/solana-sdk/tsconfig.json
+npx tsx --test packages/solana-sdk/tests/*.test.ts
+node --experimental-strip-types --test ../packages/solana-model/*.test.mjs
+npm run build
+```
+
+From `solana/`, use the pinned build and VM commands in [BUILD.md](../solana/BUILD.md). Rebuild the ELF before runtime tests after every program change. Changing the program ID also changes the reviewed binary; the fixture hash is not production evidence.
+
+For app inspection with default configuration, run `npm run dev` in `app/` and visit `/solana`. It displays the release gates and cannot sign or accept funds. To test real devnet flows later, an operator must first supply a reviewed, immutable devnet deployment and configure `SOLANA_ENABLED=1`, `SOLANA_CLUSTER=devnet`, `SOLANA_DEVNET_PROGRAM_ID` and an HTTPS `SOLANA_RPC_URL`. Do not mislabel a mainnet RPC as devnet; the server verifies genesis identity.
+
+## Required before Kevin deploys a production candidate
+
+- [ ] Approve the exact economic specification: supply/decimals, fee tiers, rounding, fixed recipients, virtual reserve bounds, real exit-liquidity examples and lack of pause/rescue. The current 1 to 1,000,000 SOL initial-valuation bounds are mechanical limits, not an economic safety certification.
+- [ ] Independent protocol security and economic review of the final source, with findings closed and reports published. Include long randomized runtime campaigns, account aliasing, donations/burns, prefunded PDAs, rent failures and dependency/loader assumptions.
+- [ ] Select and securely control the real program identity outside this repository. Bind it consistently into the program and reviewed deployment metadata, then commit and rebuild. Never commit keypairs or credentials.
+- [ ] Independently reproduce the reviewed binary with recorded toolchain provenance, exact source commit and SHA-256. Check current target-cluster runtime features, dependencies, compute and rent behavior.
+- [ ] Rehearse deployment, immutability and full lifecycle on a disposable devnet identity. Activation intentionally cannot proceed while an upgrade authority remains. Never revoke the production authority merely to test whether code works.
+- [ ] Real desktop/mobile wallet matrix: connect, switch/disconnect, reject, expired review, simulation error, tab/navigation races, multi-tab locking, RPC timeout after send, reload recovery, finalized failure and successful launch/buy/sell/claim. No user-funded mainnet test is automated here.
+
+## Required product and operations work before broad mainnet rollout
+
+- [ ] Durable paginated discovery and history. Current discovery reads all program pool accounts and displays at most 50; the RPC response cap is 4 MiB. Recent history reads at most 100 signatures. Neither is a production-scale indexer.
+- [ ] Finalized checkpoint/reorg recovery, backfill and independent account reconciliation; full holder/transfer indexing before showing holder counts, lifetime volume or complete history.
+- [ ] RPC provider quotas, edge-level rate limiting and monitoring. Current per-IP and process-wide budgets are a backstop, not a distributed abuse-control system. Account-provider outages must not cause automatic financial retries.
+- [ ] Approve immutable metadata hosting/hash policy. Canonical name/symbol/URI live in the pool; no Metaplex metadata mirror is supplied, so third-party wallets may show the mint address rather than branded token metadata.
+- [ ] Keep Solana separate until chain-aware discovery, address handling and product integration are verified. Existing EVM profile/post authentication does not authenticate Solana wallets; that integration is not implemented in this candidate.
+- [ ] Publish risk disclosure, verified recovery instructions and an incident plan without a pause or rescue switch. Frontend rollback cannot recover funds from a flawed immutable program.
+- [ ] Independently arrange any explorer/wallet/aggregator discovery. No Meteora, Jupiter, GeckoTerminal or DEX Screener listing is implied by this program.
+
+## Production deployment and read-only verification
+
+Kevin performs deployment and any authority removal manually, under separate approval and after the preceding review gates. This repository's CI and scripts do not deploy, sign with operator keys, or revoke authorities.
+
+Prepare a candidate manifest using the schema in `app/src/lib/solana/deployment.json`: actual program/ProgramData, mainnet genesis, source commit, reviewed binary hash and review report URLs. Keep `releaseApproved: false` until final evidence is checked. From `app/`, set `SOLANA_VERIFY_RPC_URL` privately, then run:
+
+```sh
+npx tsx packages/solana-sdk/scripts/verify-manifest.ts
+```
+
+This read-only command checks the cluster, linked immutable launch and legacy Token program accounts, exact ELF bytes and hash, and emits a finalized observation slot. It does not create an audit or approve release. Repeat against an independent trusted RPC and retain both reports with the build attestation.
+
+Only after review and rehearsal should maintainers commit the populated manifest with `releaseApproved: true` and enable `SOLANA_ENABLED=1`, `SOLANA_CLUSTER=mainnet-beta`, and an HTTPS `SOLANA_RPC_URL`. An environment flag alone cannot bypass the incomplete manifest. Keep API credentials server-side; there is no server signing key.
+
+## Rollback boundaries
+
+Set `SOLANA_ENABLED=0` to close this website's Solana entry points. Preserve signed-transaction records and show recovery guidance before any public shutdown. This does not stop the immutable on-chain program, revoke unused wallet signatures, change pool terms, withdraw liquidity or undo trades. A protocol fix requires a new reviewed program ID for future pools; existing pools cannot be migrated by an admin.
diff --git a/docs/SOLANA_WORKSPACE.md b/docs/SOLANA_WORKSPACE.md
new file mode 100644
index 00000000..6d7f461e
--- /dev/null
+++ b/docs/SOLANA_WORKSPACE.md
@@ -0,0 +1,21 @@
+# Solana review workspace
+
+The first integration stays at `/solana`, separate from the EVM launchpad until verification. This was explicitly selected for the review build. Existing EVM wallet, routes, storage, charts, launch and fee flows are unchanged.
+
+## Direction contract
+
+THESIS: make irreversible launch terms and actual custody readable before requesting a wallet signature. Extend Openlaunch, not a new visual identity.
+
+OWN-WORLD: inherit the existing Geist typography, paper/card/ink theme tokens, blue action color, shared buttons and inputs. Use ordinary lists and definition rows, not decorative dashboards.
+
+STORY: inspect deployment status, connect a Solana wallet, prepare immutable terms, review and activate, then inspect real reserves, trade or claim earned fees. An uncertain submission remains visible until reconciled.
+
+FIRST VIEWPORT: title and cluster status, wallet connection, a compact pool lookup and launch entry. Pool detail puts market facts beside the trade form; on mobile facts precede the form. Supply, virtual pricing offset and real SOL are separate labels.
+
+FORM: narrow extension of the incumbent transaction flow, with a separate route explicitly approved by the user. No concept roll or replacement brand.
+
+FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance. The existing identity is inherited from `app/src/components/ui.ts` and `app/src/app/globals.css`; no new raster assets or visual-system changes are introduced.
+
+## Boundaries
+
+No mock balances, fabricated candles, external listing claims, private-key inputs, hidden signing, or automatic retry of an uncertain transaction. Mainnet remains blocked by an unapproved deployment manifest. The first review workspace is not a claim that the broader production rollout or audit is complete.
From c9f34d6954bbb747b6bbe9828c3f78a22bbf933b Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 15:39:59 +0530
Subject: [PATCH 4/9] fix(solana): lock optional websocket peer for npm 10
CI uses npm 10 and requires jayson's ws 7 optional UTF-8 validator in the lockfile. Add its pinned entry without altering other dependencies or platform metadata.
---
app/package-lock.json | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/app/package-lock.json b/app/package-lock.json
index 0796d9ec..3895f6b8 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -6417,6 +6417,21 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
+ "node_modules/jayson/node_modules/utf-8-validate": {
+ "version": "5.0.10",
+ "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
+ "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "node-gyp-build": "^4.3.0"
+ },
+ "engines": {
+ "node": ">=6.14.2"
+ }
+ },
"node_modules/jayson/node_modules/ws": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
From d6812e5a275bd9ffca01a95ea23c28a57de7f555 Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 15:40:47 +0530
Subject: [PATCH 5/9] fix(ci): use portable SBF diagnostic scanning
GitHub's Ubuntu image lacks ripgrep. Keep stack and symbol diagnostics mandatory with the runner's standard grep instead.
---
solana/scripts/check-sbf.sh | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/solana/scripts/check-sbf.sh b/solana/scripts/check-sbf.sh
index 2c522966..30dc078f 100644
--- a/solana/scripts/check-sbf.sh
+++ b/solana/scripts/check-sbf.sh
@@ -13,10 +13,12 @@ if [[ "$tool_version" != *'cargo-build-sbf 4.2.0'* ]]; then
printf '%s\n' 'Expected cargo-build-sbf 4.2.0; review toolchain changes explicitly.' >&2
exit 1
fi
-command -v rg >/dev/null || { printf '%s\n' 'ripgrep is required to reject stack diagnostics.' >&2; exit 1; }
+# Hosted Ubuntu runners do not guarantee ripgrep. POSIX grep keeps this
+# mandatory diagnostic gate available without an extra CI-only dependency.
+command -v grep >/dev/null || { printf '%s\n' 'grep is required to reject stack diagnostics.' >&2; exit 1; }
mkdir -p target
"$build_sbf" --tools-version v1.56 --arch v3 --manifest-path programs/launch_pool/Cargo.toml -- --locked 2>&1 | tee target/sbf-build.log
-if rg -n -i 'stack offset .*exceed|stack frame size .*exceed|undefined symbol|call depth exceeded' target/sbf-build.log; then
+if grep -E -n -i 'stack offset .*exceed|stack frame size .*exceed|undefined symbol|call depth exceeded' target/sbf-build.log; then
printf '%s\n' 'SBF diagnostics require review; do not release this binary.' >&2
exit 1
fi
From aefd0bdf2e4d96a86898a6c4481c2268c5285988 Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 17:05:29 +0530
Subject: [PATCH 6/9] fix(solana): unblock the workspace in production and
harden the send path
Fixes the blockers from the PR #86 review.
- Origin check: the RPC proxy compared the browser Origin with req.url,
which is the bind address (0.0.0.0:3000) under the standalone server, so
every browser call got a 403 in production. It now compares with the
addressed host (X-Forwarded-Host, else Host). Verified on the standalone
build: the site's origin passes, other sites still get 403.
- RPC load: a pool poll is one getMultipleAccounts call (pool, custody,
wallet and token account from one bank) instead of four, the
immutability check reads the 45-byte ProgramData header instead of the
whole ELF, the signing path has its own budget so polling cannot starve
sends, and the per-IP limit uses the shared clientIp/rateLimited helpers
(last forwarded hop, bounded key).
- Pool list: active pools are filtered on-chain and listed as addresses
only, then data is read for the rows shown, so prepared and cancelled
tombstones can no longer push the list past the 4 MiB proxy cap.
- History: the pool is checked before the shared budget is charged
(addresses with no active pool get a cached 404), results are keyed by
pool and sequence, confirmed transactions are cached so a refresh fetches
only new ones, and a failed refresh keeps the last chart on screen.
- Send results: the proxy keeps the JSON-RPC code, transaction error and
bounded program logs (not provider text) and passes provider 429s
through. The SDK reports a refused preflight or a 429 as "rejected" with
the program's reason and releases the wallet at once; AlreadyProcessed
still reconciles.
- Trade expiry: quotes expire 300 slots after the snapshot, longer than the
blockhash, and confirm checks both deadlines from one bank before signing.
- Dropped sends: no more maxRetries: 0, and an unseen signature's identical
bytes are re-sent every 8 s while it can still land.
- False expiry: expiry is decided from one finalized bank (getEpochInfo)
and trusted only from a status read whose node had processed that bank,
so a lagging node cannot make a landed trade look expired.
- CodeQL js/polynomial-redos in the SDK amount formatter.
Tests: app 1,173 passed (10 skipped), SDK 29, model 10; tsc, eslint and
next build clean.
---
app/packages/solana-sdk/src/accounts.ts | 78 ++++++++++--
app/packages/solana-sdk/src/math.ts | 5 +-
app/packages/solana-sdk/src/transactions.ts | 64 ++++++++--
.../solana-sdk/tests/accounts.test.ts | 51 +++++++-
.../solana-sdk/tests/transactions.test.ts | 45 ++++++-
.../app/api/solana/history/[pool]/route.ts | 113 ++++++++++++------
app/src/app/api/solana/rpc/route.ts | 72 ++++++-----
app/src/components/solana/LaunchPanel.tsx | 7 +-
app/src/components/solana/PoolPanel.tsx | 64 ++++++----
app/src/components/solana/SolanaHistory.tsx | 10 +-
app/src/components/solana/SolanaWorkspace.tsx | 47 +++-----
app/src/lib/solana/config.ts | 10 ++
app/src/lib/solana/history.test.ts | 44 ++++++-
app/src/lib/solana/history.ts | 42 +++++--
app/src/lib/solana/rpc-errors.test.ts | 54 +++++++++
app/src/lib/solana/rpc-errors.ts | 48 ++++++++
app/src/lib/solana/rpc-policy.test.ts | 10 ++
app/src/lib/solana/rpc-policy.ts | 1 +
app/src/lib/solana/same-origin.test.ts | 57 +++++++++
app/src/lib/solana/same-origin.ts | 25 ++++
app/src/lib/solana/useSolanaAction.ts | 52 +++++++-
docs/SOLANA_RELEASE.md | 2 +-
22 files changed, 734 insertions(+), 167 deletions(-)
create mode 100644 app/src/lib/solana/rpc-errors.test.ts
create mode 100644 app/src/lib/solana/rpc-errors.ts
create mode 100644 app/src/lib/solana/same-origin.test.ts
create mode 100644 app/src/lib/solana/same-origin.ts
diff --git a/app/packages/solana-sdk/src/accounts.ts b/app/packages/solana-sdk/src/accounts.ts
index 9aec21fc..d9b5792a 100644
--- a/app/packages/solana-sdk/src/accounts.ts
+++ b/app/packages/solana-sdk/src/accounts.ts
@@ -2,12 +2,18 @@ import { Buffer } from "buffer";
import { PublicKey, type AccountInfo, type Connection } from "@solana/web3.js";
import { discriminator, Reader, u64, utf8 } from "./encoding.ts";
import { claimableFees, FIXED_SUPPLY, FEE_BPS, MAX_VIRTUAL_SOL, MIN_VIRTUAL_SOL, TOKEN_DECIMALS, validateCurve, type CurveState } from "./math.ts";
+import { encodeBase58 } from "./transactions.ts";
export const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
export const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
export const UPGRADEABLE_LOADER_ID = new PublicKey("BPFLoaderUpgradeab1e11111111111111111111111");
export const POOL_ACCOUNT_SPACE = 802;
export const SOL_VAULT_SPACE = 49;
+/** Pool layout: 8-byte discriminator, version, then the status byte (0 prepared, 1 active, 2 cancelled). */
+export const POOL_STATUS_OFFSET = 9;
+const POOL_STATUS_ACTIVE = 1;
+/** The proxy accepts at most 16 keys per getMultipleAccounts; a snapshot uses 5 of them. */
+export const MAX_SNAPSHOT_EXTRA_ACCOUNTS = 11;
export type PoolAddresses = {
pool: PublicKey; mint: PublicKey; tokenVault: PublicKey; reserve: PublicKey;
fees: PublicKey; mintAuthority: PublicKey; bump: number;
@@ -17,6 +23,11 @@ export function derivePoolAddresses(programId: PublicKey, creator: PublicKey, no
const derive = (seed: string) => PublicKey.findProgramAddressSync([utf8.encode(seed), pool.toBytes()], programId)[0];
return { pool, bump, mint: derive("mint"), tokenVault: derive("token"), reserve: derive("reserve"), fees: derive("fees"), mintAuthority: derive("mint-authority") };
}
+/** Custody accounts are seeded by the pool address alone, so they are known before the pool is read. */
+export function deriveCustodyAddresses(programId: PublicKey, pool: PublicKey): Pick {
+ const derive = (seed: string) => PublicKey.findProgramAddressSync([utf8.encode(seed), pool.toBytes()], programId)[0];
+ return { mint: derive("mint"), tokenVault: derive("token"), reserve: derive("reserve"), fees: derive("fees") };
+}
export function deriveAssociatedTokenAddress(mint: PublicKey, owner: PublicKey): PublicKey {
return PublicKey.findProgramAddressSync([owner.toBytes(), TOKEN_PROGRAM_ID.toBytes(), mint.toBytes()], ASSOCIATED_TOKEN_PROGRAM_ID)[0];
}
@@ -83,7 +94,8 @@ export function decodeImmutableProgram(programId: PublicKey, executable: Account
}
export async function verifyImmutableProgram(connection: Connection, programId: PublicKey): Promise<{ programData: PublicKey; deploymentSlot: bigint; slot: number }> {
const programData = programDataAddress(programId);
- const { context, value } = await connection.getMultipleAccountsInfoAndContext([programId, programData], { commitment: "finalized" });
+ // Only the 45-byte ProgramData header is checked; never download the whole ELF on every load and review.
+ const { context, value } = await connection.getMultipleAccountsInfoAndContext([programId, programData], { commitment: "finalized", dataSlice: { offset: 0, length: 45 } });
if (!value[0] || !value[1]) throw new Error("Program is not deployed");
return { programData, ...decodeImmutableProgram(programId, value[0], programData, value[1]), slot: context.slot };
}
@@ -101,17 +113,33 @@ function verifySolVault(info: AccountInfo, programId: PublicKey, pool: P
if (actualLamports(info) < rentFloor + liability) throw new Error("SOL vault is underfunded");
}
export type PoolSnapshot = { address: PublicKey; addresses: PoolAddresses; pool: PoolAccount; slot: number };
+/** The address holds no valid pool (missing, another kind of account, or failed custody checks). Retrying will not help. */
+export class PoolStateError extends Error {}
export async function fetchPoolSnapshot(connection: Connection, programId: PublicKey, address: PublicKey): Promise {
- const first = await connection.getAccountInfo(address, "confirmed");
- if (!first) throw new Error("Pool not found");
- if (first.data.length !== POOL_ACCOUNT_SPACE) throw new Error("Unexpected on-chain pool allocation");
- const initial = decodePoolAccount(address, first, programId);
- const addresses = derivePoolAddresses(programId, initial.creator, initial.nonce);
+ return (await readPoolSnapshot(connection, programId, address)).snapshot;
+}
+/**
+ * The pool, its custody and up to 11 caller accounts (a wallet and its token account, say) in one request, so one poll
+ * is one RPC call and every account is read from the same bank. `extra` holds the caller accounts in order.
+ */
+export async function readPoolSnapshot(connection: Connection, programId: PublicKey, address: PublicKey, extra: readonly PublicKey[] = []): Promise<{ snapshot: PoolSnapshot; extra: (AccountInfo | null)[] }> {
+ if (extra.length > MAX_SNAPSHOT_EXTRA_ACCOUNTS) throw new RangeError("Too many accounts for one snapshot read");
+ const custody = deriveCustodyAddresses(programId, address);
// One bank/context for state and custody; never mix independent vault read slots.
- const { context, value } = await connection.getMultipleAccountsInfoAndContext([address, addresses.mint, addresses.tokenVault, addresses.reserve, addresses.fees], { commitment: "confirmed" });
- if (!value[0]) throw new Error("Pool not found");
+ const { context, value } = await connection.getMultipleAccountsInfoAndContext([address, custody.mint, custody.tokenVault, custody.reserve, custody.fees, ...extra], { commitment: "confirmed" });
+ // RPC failures above propagate as they are; everything below is about what the address holds.
+ try {
+ return { snapshot: validatedSnapshot(programId, address, value, context.slot), extra: value.slice(5) };
+ } catch (e) {
+ throw e instanceof PoolStateError ? e : new PoolStateError(e instanceof Error ? e.message : "Invalid pool state");
+ }
+}
+function validatedSnapshot(programId: PublicKey, address: PublicKey, value: (AccountInfo | null)[], slot: number): PoolSnapshot {
+ if (!value[0]) throw new PoolStateError("Pool not found");
if (value[0].data.length !== POOL_ACCOUNT_SPACE) throw new Error("Unexpected on-chain pool allocation");
const pool = decodePoolAccount(address, value[0], programId);
+ // The decoder proved the address is this creator/nonce's canonical pool, so these match the custody read above.
+ const addresses = derivePoolAddresses(programId, pool.creator, pool.nonce);
if (pool.status === "active") {
const [, mint, token, reserve, fees] = value;
if (!mint || !token || !reserve || !fees) throw new Error("Missing active custody accounts");
@@ -120,5 +148,37 @@ export async function fetchPoolSnapshot(connection: Connection, programId: Publi
verifySolVault(reserve, programId, address, 0, pool.realSolReserves);
verifySolVault(fees, programId, address, 1, pool.feesEarned - pool.recipients.reduce((sum, r) => sum + r.paid, 0n));
}
- return { address, addresses, pool, slot: context.slot };
+ return { address, addresses, pool, slot };
+}
+
+/**
+ * Active pools, with data read only for the rows returned. Prepared and cancelled pools are never closed, so the listing
+ * filters on the status byte and asks for addresses alone: a few hundred bytes per active pool, however many tombstones
+ * exist. Rows are in address order, which is stable but not a ranking; `total` counts every active pool.
+ */
+export async function fetchActivePools(connection: Connection, programId: PublicKey, limit = 50): Promise<{ total: number; pools: { address: PublicKey; pool: PoolAccount }[] }> {
+ const rows = await connection.getProgramAccounts(programId, {
+ commitment: "confirmed",
+ dataSlice: { offset: 0, length: 0 },
+ filters: [
+ { dataSize: POOL_ACCOUNT_SPACE },
+ { memcmp: { offset: 0, bytes: encodeBase58(discriminator("account", "Pool")) } },
+ { memcmp: { offset: POOL_STATUS_OFFSET, bytes: encodeBase58(Uint8Array.of(POOL_STATUS_ACTIVE)) } },
+ ],
+ });
+ const keys = rows.map((row) => row.pubkey).sort((x, y) => (x.toBase58() < y.toBase58() ? -1 : 1)).slice(0, limit);
+ const chunks: PublicKey[][] = [];
+ for (let i = 0; i < keys.length; i += 16) chunks.push(keys.slice(i, i + 16));
+ const infos = (await Promise.all(chunks.map((chunk) => connection.getMultipleAccountsInfo(chunk, "confirmed")))).flat();
+ const pools = keys.flatMap((address, i) => {
+ const info = infos[i];
+ if (!info) return [];
+ try {
+ const pool = decodePoolAccount(address, info, programId);
+ return pool.status === "active" ? [{ address, pool }] : [];
+ } catch {
+ return [];
+ }
+ });
+ return { total: rows.length, pools };
}
diff --git a/app/packages/solana-sdk/src/math.ts b/app/packages/solana-sdk/src/math.ts
index 20d2c34f..6c722d71 100644
--- a/app/packages/solana-sdk/src/math.ts
+++ b/app/packages/solana-sdk/src/math.ts
@@ -103,6 +103,9 @@ export function formatAmount(value: bigint, decimals: number): string {
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 18) throw new RangeError("Invalid decimal precision");
const text = value.toString().padStart(decimals + 1, "0");
if (decimals === 0) return text;
- const fraction = text.slice(-decimals).replace(/0+$/, "");
+ // Trailing zeros trimmed by index, not by regex: the input is library data (CodeQL js/polynomial-redos).
+ let end = decimals;
+ while (end > 0 && text[text.length - decimals + end - 1] === "0") end--;
+ const fraction = text.slice(text.length - decimals, text.length - decimals + end);
return `${text.slice(0, -decimals)}${fraction ? `.${fraction}` : ""}`;
}
diff --git a/app/packages/solana-sdk/src/transactions.ts b/app/packages/solana-sdk/src/transactions.ts
index 4ed66149..23ea3daf 100644
--- a/app/packages/solana-sdk/src/transactions.ts
+++ b/app/packages/solana-sdk/src/transactions.ts
@@ -1,7 +1,26 @@
import { Buffer } from "buffer";
-import { PublicKey, Transaction, VersionedTransaction, type Connection, type TransactionInstruction } from "@solana/web3.js";
+import { PublicKey, SendTransactionError, Transaction, VersionedTransaction, type Connection, type TransactionInstruction } from "@solana/web3.js";
export const MAX_TRANSACTION_BYTES = 1232;
+/** `PoolError` messages in program order: Anchor numbers custom errors from 6000. */
+const POOL_ERRORS = [
+ "Arithmetic limit exceeded", "Invalid immutable fee tier", "Invalid fixed recipients", "Invalid token inventory",
+ "Virtual SOL is outside the supported range", "Actual reserves do not cover accounted obligations",
+ "Trade or claim amount rounds to zero", "Operation is not allowed in this pool state", "Invalid mint authority, supply, or decimals",
+ "Invalid custody vault", "Minimum output not met", "Protocol account aliases are not permitted", "Unsupported pool version",
+ "Invalid or oversized metadata", "Activation requires this exact program to be immutable", "Quote expired at its maximum slot",
+];
+/** A readable reason for a failed simulation or a refused send: the program's own error message when there is one. */
+export function describeTransactionError(err: unknown, logs: readonly string[] = []): string {
+ for (const line of logs) {
+ const anchor = /Error Message: (.+?)\.?$/.exec(line);
+ if (anchor) return anchor[1];
+ }
+ const text = typeof err === "string" ? err : JSON.stringify(err) ?? "";
+ const custom = /"Custom":(\d+)/.exec(text);
+ if (custom) return POOL_ERRORS[Number(custom[1]) - 6000] ?? `Program error ${custom[1]}`;
+ return text;
+}
export type PreparedTransaction = {
transaction: Transaction; wireBytes: Uint8Array; messageBytes: Uint8Array;
blockhash: string; lastValidBlockHeight: number;
@@ -17,7 +36,7 @@ export async function prepareTransaction(connection: Connection, payer: PublicKe
export async function simulatePreparedTransaction(connection: Connection, prepared: PreparedTransaction): Promise<{ unitsConsumed: number | undefined; logs: string[] }> {
const transaction = VersionedTransaction.deserialize(prepared.wireBytes);
const result = await connection.simulateTransaction(transaction, { sigVerify: false, replaceRecentBlockhash: false, commitment: "confirmed" });
- if (result.value.err) throw new Error(`Simulation failed: ${JSON.stringify(result.value.err)}`);
+ if (result.value.err) throw new Error(`Simulation failed: ${describeTransactionError(result.value.err, result.value.logs ?? [])}`);
return { unitsConsumed: result.value.unitsConsumed, logs: result.value.logs ?? [] };
}
@@ -41,22 +60,42 @@ export function validateSignedTransaction(expectedMessage: Uint8Array, signedByt
const bytes = transaction.serialize({ requireAllSignatures: true, verifySignatures: true });
return { bytes, signature: encodeBase58(transaction.signature) };
}
-export type SubmissionResult = { signature: string; status: "submitted" | "unknown"; error?: string };
-/** Persist the local signature + expiry BEFORE calling. A transport error is not a failed transaction. */
+export type SubmissionResult = { signature: string; status: "submitted" | "rejected" | "unknown"; error?: string };
+/**
+ * Persist the local signature + expiry BEFORE calling. A transport error is not a failed transaction ("unknown"). An
+ * error the RPC returns for this preflighted send, or a 429 from the proxy or provider, means the transaction was not
+ * forwarded ("rejected"): it cannot land, so a fresh review is safe. The one exception is "AlreadyProcessed", which
+ * means the same signature already landed and must be reconciled.
+ */
export async function submitSignedTransaction(connection: Connection, signed: ValidatedSignedTransaction): Promise {
try {
- const returned = await connection.sendRawTransaction(signed.bytes, { skipPreflight: false, preflightCommitment: "confirmed", maxRetries: 0 });
+ // No maxRetries: the RPC keeps forwarding the signed bytes until the blockhash expires.
+ const returned = await connection.sendRawTransaction(signed.bytes, { skipPreflight: false, preflightCommitment: "confirmed" });
if (returned !== signed.signature) return { signature: signed.signature, status: "unknown", error: "RPC returned an unexpected signature; reconcile the locally signed transaction" };
return { signature: signed.signature, status: "submitted" };
- } catch {
+ } catch (e) {
+ if (e instanceof SendTransactionError && !/AlreadyProcessed/.test(e.transactionError.message ?? ""))
+ return { signature: signed.signature, status: "rejected", error: describeTransactionError(e.transactionError.message, e.transactionError.logs) };
+ if (!(e instanceof SendTransactionError) && e instanceof Error && /^429\b/.test(e.message))
+ return { signature: signed.signature, status: "rejected", error: "Solana RPC is busy" };
return { signature: signed.signature, status: "unknown", error: "Submission result unknown. Check this signature before submitting a replacement." };
}
}
-export type Reconciliation = { status: "confirmed" | "finalized" | "failed" | "pending" | "expired"; error?: unknown };
+/** Re-send the identical signed bytes while the signature is unseen. A signature lands at most once, so this cannot duplicate. */
+export async function rebroadcastSignedTransaction(connection: Connection, bytes: Uint8Array): Promise {
+ try {
+ await connection.sendRawTransaction(bytes, { skipPreflight: false, preflightCommitment: "confirmed" });
+ } catch {
+ // Already processed, expired or refused for now: reconciliation decides what happened.
+ }
+}
+/** `unseen`: no node has reported the signature yet, so re-sending its bytes may still help it land. */
+export type Reconciliation = { status: "confirmed" | "finalized" | "failed" | "pending" | "expired"; error?: unknown; unseen?: true };
export async function reconcileTransaction(connection: Connection, signature: string, lastValidBlockHeight: number): Promise {
if (!Number.isSafeInteger(lastValidBlockHeight) || lastValidBlockHeight <= 0) throw new Error("Invalid transaction expiry");
- // Check finalized block height before historical status: if expired, no new landing can race this read.
- const height = await connection.getBlockHeight("finalized");
+ // One finalized bank gives both the slot and the block height. Read it before historical status: if it is past the
+ // expiry, no new landing can race the status read.
+ const finalized = await connection.getEpochInfo("finalized");
const result = await connection.getSignatureStatuses([signature], { searchTransactionHistory: true });
const status = result.value[0];
if (status) {
@@ -67,5 +106,10 @@ export async function reconcileTransaction(connection: Connection, signature: st
if (status.confirmationStatus === "confirmed") return { status: "confirmed" };
return { status: "pending" };
}
- return { status: height > lastValidBlockHeight ? "expired" : "pending" };
+ const height = finalized.blockHeight;
+ if (height === undefined || !Number.isSafeInteger(height) || height <= lastValidBlockHeight) return { status: "pending", unseen: true };
+ // The two reads can reach different nodes behind a load balancer. "Not found" proves expiry only from a node that had
+ // already processed the finalized bank past the expiry; a lagging node would miss a transaction that landed.
+ if (result.context.slot < finalized.absoluteSlot) return { status: "pending", unseen: true };
+ return { status: "expired" };
}
diff --git a/app/packages/solana-sdk/tests/accounts.test.ts b/app/packages/solana-sdk/tests/accounts.test.ts
index 04226846..d3ae5db0 100644
--- a/app/packages/solana-sdk/tests/accounts.test.ts
+++ b/app/packages/solana-sdk/tests/accounts.test.ts
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import { Buffer } from "buffer";
import { PublicKey, type AccountInfo, type Connection } from "@solana/web3.js";
-import { decodeImmutableProgram, decodePoolAccount, derivePoolAddresses, fetchPoolSnapshot, POOL_ACCOUNT_SPACE, programDataAddress, TOKEN_PROGRAM_ID, UPGRADEABLE_LOADER_ID } from "../src/accounts.ts";
+import { decodeImmutableProgram, decodePoolAccount, derivePoolAddresses, fetchActivePools, fetchPoolSnapshot, POOL_ACCOUNT_SPACE, PoolStateError, programDataAddress, readPoolSnapshot, TOKEN_PROGRAM_ID, UPGRADEABLE_LOADER_ID, verifyImmutableProgram } from "../src/accounts.ts";
+import { encodeBase58 } from "../src/transactions.ts";
import { discriminator, textField, u8, u16, u64 } from "../src/encoding.ts";
import { FIXED_SUPPLY } from "../src/math.ts";
@@ -43,11 +44,19 @@ function vaultBytes(kind: number): Buffer { return Buffer.concat([discriminator(
test("snapshot accepts donated surplus, rejects invalid custody and reads one context", async () => {
const fullPool = Buffer.alloc(POOL_ACCOUNT_SPACE); poolBytes(true).copy(fullPool);
const values = [account(fullPool), account(mintBytes(), TOKEN_PROGRAM_ID), account(tokenBytes(), TOKEN_PROGRAM_ID), account(vaultBytes(0)), account(vaultBytes(1))];
+ let calls = 0;
+ let requested: PublicKey[] = [];
const fake = {
- getAccountInfo: async () => values[0],
- getMultipleAccountsInfoAndContext: async () => ({ context: { slot: 5 }, value: values }),
+ getMultipleAccountsInfoAndContext: async (keys: PublicKey[]) => { calls++; requested = keys; return { context: { slot: 5 }, value: [...values, ...keys.slice(5).map(() => null)] }; },
} as unknown as Connection;
assert.equal((await fetchPoolSnapshot(fake, programId, a.pool)).slot, 5);
+ // One request: custody comes from the pool address alone, so no first read of the pool is needed.
+ assert.equal(calls, 1);
+ assert.deepEqual(requested.map(String), [a.pool, a.mint, a.tokenVault, a.reserve, a.fees].map(String));
+ const wallet = new PublicKey(new Uint8Array(32).fill(9));
+ const read = await readPoolSnapshot(fake, programId, a.pool, [wallet]);
+ assert.equal(requested.at(-1)?.toBase58(), wallet.toBase58()); assert.deepEqual(read.extra, [null]);
+ await assert.rejects(readPoolSnapshot(fake, programId, a.pool, Array(12).fill(wallet)), /Too many/);
values[2].data.writeUInt32LE(1, 72);
await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /custody/);
values[2].data.writeUInt32LE(0, 72);
@@ -56,3 +65,39 @@ test("snapshot accepts donated surplus, rejects invalid custody and reads one co
values[3].lamports = Number.MAX_SAFE_INTEGER + 1;
await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /precision/);
});
+test("the pool list filters active pools on-chain and reads data only for the rows it shows", async () => {
+ const fullPool = Buffer.alloc(POOL_ACCOUNT_SPACE); poolBytes(true).copy(fullPool);
+ const keys = Array.from({ length: 40 }, (_, i) => new PublicKey(new Uint8Array(32).fill(i + 10)));
+ let config: { dataSlice?: unknown; filters?: unknown[] } = {};
+ const batches: number[] = [];
+ const fake = {
+ getProgramAccounts: async (_program: PublicKey, given: typeof config) => { config = given; return keys.map((pubkey) => ({ pubkey, account: account(Buffer.alloc(0)) })); },
+ // Only the canonical pool decodes; the other addresses stand in for rows that fail validation.
+ getMultipleAccountsInfo: async (chunk: PublicKey[]) => { batches.push(chunk.length); return chunk.map((key) => (key.equals(a.pool) ? account(fullPool) : null)); },
+ } as unknown as Connection;
+ keys[0] = a.pool;
+ const result = await fetchActivePools(fake, programId, 34);
+ assert.deepEqual(config.dataSlice, { offset: 0, length: 0 });
+ assert.deepEqual(config.filters, [
+ { dataSize: POOL_ACCOUNT_SPACE },
+ { memcmp: { offset: 0, bytes: encodeBase58(discriminator("account", "Pool")) } },
+ { memcmp: { offset: 9, bytes: "2" } },
+ ]);
+ assert.equal(result.total, 40);
+ assert.deepEqual(batches, [16, 16, 2]);
+ assert.deepEqual(result.pools.map((p) => p.address.toBase58()), [a.pool.toBase58()]);
+});
+test("the immutability check downloads only the ProgramData header", async () => {
+ let given: { dataSlice?: unknown } = {};
+ const fake = { getMultipleAccountsInfoAndContext: async (_keys: PublicKey[], config: typeof given) => { given = config; return { context: { slot: 1 }, value: [null, null] }; } } as unknown as Connection;
+ await assert.rejects(verifyImmutableProgram(fake, programId), /not deployed/);
+ assert.deepEqual(given.dataSlice, { offset: 0, length: 45 });
+});
+test("an address holding no valid pool is a PoolStateError; RPC failures are not", async () => {
+ const missing = { getMultipleAccountsInfoAndContext: async (keys: PublicKey[]) => ({ context: { slot: 1 }, value: keys.map(() => null) }) } as unknown as Connection;
+ await assert.rejects(fetchPoolSnapshot(missing, programId, a.pool), (e: unknown) => e instanceof PoolStateError && /not found/.test(e.message));
+ const other = { getMultipleAccountsInfoAndContext: async (keys: PublicKey[]) => ({ context: { slot: 1 }, value: keys.map((_, i) => (i === 0 ? account(Buffer.alloc(40), TOKEN_PROGRAM_ID) : null)) }) } as unknown as Connection;
+ await assert.rejects(fetchPoolSnapshot(other, programId, a.pool), PoolStateError);
+ const down = { getMultipleAccountsInfoAndContext: async () => { throw new Error("503 Service Unavailable"); } } as unknown as Connection;
+ await assert.rejects(fetchPoolSnapshot(down, programId, a.pool), (e: unknown) => !(e instanceof PoolStateError));
+});
diff --git a/app/packages/solana-sdk/tests/transactions.test.ts b/app/packages/solana-sdk/tests/transactions.test.ts
index 34c5c6e7..709cdc99 100644
--- a/app/packages/solana-sdk/tests/transactions.test.ts
+++ b/app/packages/solana-sdk/tests/transactions.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { Keypair, PublicKey, SystemProgram, Transaction, type Connection } from "@solana/web3.js";
-import { encodeBase58, prepareTransaction, reconcileTransaction, submitSignedTransaction, validateSignedTransaction } from "../src/transactions.ts";
+import { Keypair, PublicKey, SendTransactionError, SystemProgram, Transaction, type Connection } from "@solana/web3.js";
+import { describeTransactionError, encodeBase58, prepareTransaction, rebroadcastSignedTransaction, reconcileTransaction, submitSignedTransaction, validateSignedTransaction } from "../src/transactions.ts";
const signer = Keypair.fromSeed(new Uint8Array(32).fill(1));
const recipient = Keypair.fromSeed(new Uint8Array(32).fill(2)).publicKey;
@@ -24,12 +24,49 @@ test("ambiguous submission retains local signature and never creates a replaceme
const result = await submitSignedTransaction(fake, signed);
assert.equal(result.signature, signed.signature); assert.equal(result.status, "unknown");
});
+test("a send refused before forwarding is rejected with the program's reason; transport errors stay unknown", async () => {
+ const tx = transaction(); const expected = tx.serializeMessage(); tx.sign(signer);
+ const signed = validateSignedTransaction(expected, tx.serialize(), signer.publicKey);
+ let thrown: unknown = null;
+ let options: Record = {};
+ const fake = { sendRawTransaction: async (_bytes: Uint8Array, given: Record) => { options = given; if (thrown) throw thrown; return signed.signature; } } as unknown as Connection;
+ assert.equal((await submitSignedTransaction(fake, signed)).status, "submitted");
+ // The RPC keeps re-forwarding until the blockhash expires: no maxRetries cap.
+ assert.equal("maxRetries" in options, false); assert.equal(options.skipPreflight, false);
+ thrown = new SendTransactionError({ action: "simulate", signature: "", transactionMessage: 'Transaction simulation failed: {"InstructionError":[1,{"Custom":6010}]}', logs: [] });
+ assert.deepEqual(await submitSignedTransaction(fake, signed), { signature: signed.signature, status: "rejected", error: "Minimum output not met" });
+ thrown = new SendTransactionError({ action: "simulate", signature: "", transactionMessage: "Transaction simulation failed", logs: ["Program log: AnchorError thrown in lib.rs:636. Error Code: Expired. Error Number: 6015. Error Message: Quote expired at its maximum slot."] });
+ assert.equal((await submitSignedTransaction(fake, signed)).error, "Quote expired at its maximum slot");
+ // The same signature already landed: never report that as a refusal.
+ thrown = new SendTransactionError({ action: "simulate", signature: "", transactionMessage: "Transaction simulation failed: AlreadyProcessed", logs: [] });
+ assert.equal((await submitSignedTransaction(fake, signed)).status, "unknown");
+ thrown = new Error('429 Too Many Requests: {"error":"Solana RPC is busy. Please retry shortly."}');
+ assert.equal((await submitSignedTransaction(fake, signed)).status, "rejected");
+ thrown = new Error('503 Service Unavailable: {"jsonrpc":"2.0"}');
+ assert.equal((await submitSignedTransaction(fake, signed)).status, "unknown");
+ thrown = new Error("network timeout");
+ await rebroadcastSignedTransaction(fake, signed.bytes);
+});
+test("transaction errors read as the program's messages", () => {
+ assert.equal(describeTransactionError({ InstructionError: [1, { Custom: 6015 }] }), "Quote expired at its maximum slot");
+ assert.equal(describeTransactionError({ InstructionError: [0, { Custom: 6000 }] }), "Arithmetic limit exceeded");
+ assert.equal(describeTransactionError({ InstructionError: [0, { Custom: 1 }] }), "Program error 1");
+ assert.equal(describeTransactionError("InsufficientFundsForFee"), "InsufficientFundsForFee");
+ assert.equal(describeTransactionError({ InstructionError: [0, { Custom: 6010 }] }, ["Program log: AnchorError occurred. Error Code: Slippage. Error Number: 6010. Error Message: Minimum output not met."]), "Minimum output not met");
+});
test("reconciliation needs finalized expiry or finalized failure before safe replacement", async () => {
let height = 99;
let value: unknown = null;
- const fake = { getBlockHeight: async () => height, getSignatureStatuses: async () => ({ value: [value] }) } as unknown as Connection;
- assert.equal((await reconcileTransaction(fake, "test", 100)).status, "pending");
+ let statusSlot = 500;
+ const fake = {
+ getEpochInfo: async () => ({ absoluteSlot: 400, blockHeight: height, epoch: 0, slotIndex: 0, slotsInEpoch: 1 }),
+ getSignatureStatuses: async () => ({ context: { slot: statusSlot }, value: [value] }),
+ } as unknown as Connection;
+ assert.deepEqual(await reconcileTransaction(fake, "test", 100), { status: "pending", unseen: true });
height = 101; assert.equal((await reconcileTransaction(fake, "test", 100)).status, "expired");
+ // A status node behind the finalized bank that proved expiry could have missed the landing: keep waiting.
+ statusSlot = 399; assert.deepEqual(await reconcileTransaction(fake, "test", 100), { status: "pending", unseen: true });
+ statusSlot = 400; assert.equal((await reconcileTransaction(fake, "test", 100)).status, "expired");
value = { err: { InstructionError: [0, "Custom"] }, confirmationStatus: "processed" };
assert.equal((await reconcileTransaction(fake, "test", 100)).status, "pending");
value = { err: { InstructionError: [0, "Custom"] }, confirmationStatus: "confirmed" };
diff --git a/app/src/app/api/solana/history/[pool]/route.ts b/app/src/app/api/solana/history/[pool]/route.ts
index e6a314c2..721c5c1d 100644
--- a/app/src/app/api/solana/history/[pool]/route.ts
+++ b/app/src/app/api/solana/history/[pool]/route.ts
@@ -1,5 +1,9 @@
import { PublicKey } from "@solana/web3.js";
-import { fetchPoolSnapshot } from "../../../../../../packages/solana-sdk/src/accounts";
+import {
+ fetchPoolSnapshot,
+ PoolStateError,
+ type PoolSnapshot,
+} from "../../../../../../packages/solana-sdk/src/accounts";
import { publicKey } from "@/lib/solana/config";
import {
createHistoryCache,
@@ -7,16 +11,41 @@ import {
type SolanaHistory,
} from "@/lib/solana/history";
import { configuredSolana, solanaConnection } from "@/lib/solana/server";
+import { clientIp } from "@/lib/profiles/http";
+import { rateLimited } from "@/lib/launchpad/editServer";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const cached = createHistoryCache();
-const buckets = new Map();
const GENESIS = {
"mainnet-beta": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
};
const noStore = { "cache-control": "no-store" };
+/** The checked cluster, so a history request does not re-read the genesis hash every time. */
+let network: { cluster: string; until: number } | null = null;
+/** Shared pool reads: many viewers of one pool cost one snapshot read every few seconds, not one each. */
+const snapshots = new Map }>();
+/** Addresses with no active pool, remembered briefly so a repeated lookup costs no RPC call. */
+const absent = new Map();
+const NO_POOL = "NO_ACTIVE_POOL";
+
+function snapshotFor(key: string, read: () => Promise) {
+ const now = Date.now();
+ const found = snapshots.get(key);
+ if (found && found.until > now) return found.read;
+ if (snapshots.size >= 256)
+ for (const [id, entry] of snapshots)
+ if (entry.until <= now) snapshots.delete(id);
+ if (snapshots.size >= 256) snapshots.delete(snapshots.keys().next().value!);
+ const entry = { until: now + 5_000, read: read() };
+ snapshots.set(key, entry);
+ // A failed read is not reused.
+ entry.read.catch(() => {
+ if (snapshots.get(key) === entry) snapshots.delete(key);
+ });
+ return entry.read;
+}
export async function GET(
req: Request,
@@ -34,43 +63,47 @@ export async function GET(
{ error: "Invalid pool address." },
{ status: 400, headers: noStore },
);
- const ip = (
- req.headers.get("fly-client-ip") ??
- req.headers.get("x-forwarded-for")?.split(",")[0] ??
- "local"
- ).slice(0, 128);
- const now = Date.now();
- const bucket = buckets.get(ip);
- if (bucket && now - bucket.at < 60_000 && bucket.count >= 30)
+ // The shared helper keys on Fly's client IP or the last forwarded hop: a spoofed header never buys a fresh bucket.
+ if (rateLimited(`solana-history:${clientIp(req).slice(0, 64)}`, 30))
return Response.json(
{ error: "Please wait before refreshing history." },
{ status: 429, headers: { ...noStore, "retry-after": "15" } },
);
- if (buckets.size >= 5000) buckets.clear();
- buckets.set(
- ip,
- bucket && now - bucket.at < 60_000
- ? { at: bucket.at, count: bucket.count + 1 }
- : { at: now, count: 1 },
- );
+ const key = `${config.cluster}:${config.programId}:${pool}`;
+ if ((absent.get(key) ?? 0) > Date.now())
+ return Response.json(
+ { error: "No active pool at this address." },
+ { status: 404, headers: noStore },
+ );
try {
- const history = await cached(
- `${config.cluster}:${config.programId}:${pool}`,
- async () => {
- const connection = solanaConnection();
- if ((await connection.getGenesisHash()) !== GENESIS[config.cluster])
- throw new Error("Wrong RPC cluster");
- const snapshot = await fetchPoolSnapshot(
- connection,
- new PublicKey(config.programId),
- new PublicKey(pool),
- );
- return fetchRecentSolanaHistory(
- connection,
- new PublicKey(config.programId),
- snapshot,
- );
- },
+ const connection = solanaConnection();
+ if (
+ !network ||
+ network.cluster !== config.cluster ||
+ network.until < Date.now()
+ ) {
+ if ((await connection.getGenesisHash()) !== GENESIS[config.cluster])
+ throw new Error("Wrong RPC cluster");
+ network = { cluster: config.cluster, until: Date.now() + 60_000 };
+ }
+ // The pool is read before the history budget is charged: an address with no active pool costs one cheap read
+ // and nothing from the shared allowance, so made-up addresses cannot crowd out real pools.
+ const snapshot = await snapshotFor(key, () =>
+ fetchPoolSnapshot(
+ connection,
+ new PublicKey(config.programId),
+ new PublicKey(pool),
+ ),
+ ).catch((error: unknown) => {
+ throw error instanceof PoolStateError ? new Error(NO_POOL) : error;
+ });
+ if (snapshot.pool.status !== "active") throw new Error(NO_POOL);
+ const history = await cached(`${key}:${snapshot.pool.sequence}`, () =>
+ fetchRecentSolanaHistory(
+ connection,
+ new PublicKey(config.programId),
+ snapshot,
+ ),
);
return Response.json(
{ ...history, cluster: config.cluster },
@@ -82,7 +115,17 @@ export async function GET(
},
);
} catch (error) {
- const busy = error instanceof Error && error.message === "HISTORY_BUSY";
+ const reason = error instanceof Error ? error.message : "";
+ if (reason === NO_POOL) {
+ if (absent.size >= 5_000) absent.clear();
+ absent.set(key, Date.now() + 60_000);
+ return Response.json(
+ { error: "No active pool at this address." },
+ { status: 404, headers: noStore },
+ );
+ }
+ if (reason === "Wrong RPC cluster") network = null;
+ const busy = reason === "HISTORY_BUSY";
return Response.json(
{
error: busy
diff --git a/app/src/app/api/solana/rpc/route.ts b/app/src/app/api/solana/rpc/route.ts
index 365453fa..7fc554a6 100644
--- a/app/src/app/api/solana/rpc/route.ts
+++ b/app/src/app/api/solana/rpc/route.ts
@@ -2,17 +2,41 @@ import { configuredSolana, solanaRpcUrl } from "@/lib/solana/server";
import { allowedSolanaRpc } from "@/lib/solana/rpc-policy";
import { boundedJson } from "@/lib/solana/bounded-json";
import { rpcBudget } from "@/lib/solana/rpc-budget";
+import { sameOriginRequest } from "@/lib/solana/same-origin";
+import { solanaRpcError } from "@/lib/solana/rpc-errors";
+import { clientIp } from "@/lib/profiles/http";
+import { rateLimited } from "@/lib/launchpad/editServer";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-const buckets = new Map();
let network: { url: string; cluster: string; expires: number } | null = null;
const GENESIS = {
"mainnet-beta": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
};
const headers = { "cache-control": "no-store" };
-const acquire = rpcBudget();
+/**
+ * An open pool tab polls one call every 8 s, and a pending transaction adds about 45 calls a minute while it confirms,
+ * so a few tabs behind one address (an office, a carrier NAT) stay well inside this.
+ */
+const PER_IP_PER_MINUTE = 240;
+/** The signing path draws on its own budget, so background polling can never starve a simulation, send or status check. */
+const SIGNING = new Set([
+ "sendTransaction",
+ "simulateTransaction",
+ "getLatestBlockhash",
+ "getFeeForMessage",
+ "getSignatureStatuses",
+ "getBlockHeight",
+ "getEpochInfo",
+]);
+const reads = rpcBudget(16, 900);
+const signing = rpcBudget(8, 300);
+const busy = () =>
+ Response.json(
+ { error: "Solana RPC is busy. Please retry shortly." },
+ { status: 429, headers: { ...headers, "retry-after": "5" } },
+ );
export async function POST(req: Request) {
const config = configuredSolana();
@@ -22,32 +46,20 @@ export async function POST(req: Request) {
{ error: "Solana is not enabled." },
{ status: 503, headers },
);
- const origin = req.headers.get("origin");
- if (origin && origin !== new URL(req.url).origin)
+ if (!sameOriginRequest(req))
return Response.json({ error: "Origin denied." }, { status: 403, headers });
if (Number(req.headers.get("content-length") ?? 0) > 12_000)
return Response.json(
{ error: "Request too large." },
{ status: 413, headers },
);
- const ip =
- req.headers.get("fly-client-ip") ??
- req.headers.get("x-forwarded-for")?.split(",")[0] ??
- "local";
- const now = Date.now();
- const bucket = buckets.get(ip);
- if (bucket && now - bucket.at < 60_000 && bucket.count >= 100)
+ // The shared helper keys on Fly's client IP or the last forwarded hop: a spoofed header never buys a fresh bucket.
+ if (rateLimited(`solana-rpc:${clientIp(req).slice(0, 64)}`, PER_IP_PER_MINUTE))
return Response.json(
{ error: "Please wait before retrying." },
{ status: 429, headers: { ...headers, "retry-after": "10" } },
);
- if (buckets.size > 10_000) buckets.clear();
- buckets.set(
- ip,
- bucket && now - bucket.at < 60_000
- ? { at: bucket.at, count: bucket.count + 1 }
- : { at: now, count: 1 },
- );
+ const now = Date.now();
let body: unknown;
try {
body = await boundedJson(req.body, 12_000);
@@ -66,12 +78,8 @@ export async function POST(req: Request) {
},
{ headers },
);
- const release = acquire();
- if (!release)
- return Response.json(
- { error: "Solana RPC is busy. Please retry shortly." },
- { status: 429, headers: { ...headers, "retry-after": "10" } },
- );
+ const release = (SIGNING.has(body.method) ? signing : reads)();
+ if (!release) return busy();
try {
if (
!network ||
@@ -91,6 +99,11 @@ export async function POST(req: Request) {
signal: AbortSignal.timeout(8_000),
cache: "no-store",
});
+ // Throttled before the request was even forwarded: busy, not unavailable.
+ if (check.status === 429) {
+ await check.body?.cancel().catch(() => {});
+ return busy();
+ }
const result = (await boundedJson(check.body, 4_096)) as {
result?: unknown;
};
@@ -109,6 +122,11 @@ export async function POST(req: Request) {
signal: AbortSignal.timeout(15_000),
cache: "no-store",
});
+ // A provider 429 is a refusal before processing: say so, so a refused send is not mistaken for an uncertain one.
+ if (response.status === 429) {
+ await response.body?.cancel().catch(() => {});
+ return busy();
+ }
if (!response.ok) throw new Error("Upstream unavailable");
const result = (await boundedJson(response.body, 4 * 1024 * 1024)) as {
jsonrpc?: unknown;
@@ -128,11 +146,7 @@ export async function POST(req: Request) {
{
jsonrpc: "2.0",
id: body.id,
- error: {
- code: -32000,
- message:
- "Solana RPC rejected this request. Check the transaction status before retrying.",
- },
+ error: solanaRpcError(result.error, body.method),
},
{ headers },
);
diff --git a/app/src/components/solana/LaunchPanel.tsx b/app/src/components/solana/LaunchPanel.tsx
index c7178110..cfb7a0c1 100644
--- a/app/src/components/solana/LaunchPanel.tsx
+++ b/app/src/components/solana/LaunchPanel.tsx
@@ -8,7 +8,7 @@ import {
POOL_ACCOUNT_SPACE,
} from "@openlaunch/solana-sdk";
import { btn, input, label, helper } from "@/components/ui";
-import { formatUnits, parseUnits } from "@/lib/solana/config";
+import { formatUnits, parseUnits, requireRent } from "@/lib/solana/config";
import type { SolanaPanelProps } from "./SolanaWorkspace";
export default function LaunchPanel({
@@ -91,8 +91,9 @@ export default function LaunchPanel({
const addresses = derivePoolAddresses(program, creator, nonce);
const pool = addresses.pool.toBase58();
// Pool::SPACE is ABI-v1 fixed maximum allocation, even with shorter text.
- const rent =
- await connection.getMinimumBalanceForRentExemption(POOL_ACCOUNT_SPACE);
+ const rent = requireRent(
+ await connection.getMinimumBalanceForRentExemption(POOL_ACCOUNT_SPACE),
+ );
await actions.prepare(
"Prepare launch",
[ix],
diff --git a/app/src/components/solana/PoolPanel.tsx b/app/src/components/solana/PoolPanel.tsx
index ffcfa57e..ff4fc7ac 100644
--- a/app/src/components/solana/PoolPanel.tsx
+++ b/app/src/components/solana/PoolPanel.tsx
@@ -16,14 +16,26 @@ import {
minimumAfterSlippage,
quoteBuy,
quoteSell,
+ readPoolSnapshot,
TOKEN_PROGRAM_ID,
type PoolSnapshot,
} from "@openlaunch/solana-sdk";
import { btn, input, label } from "@/components/ui";
-import { explorerUrl, formatUnits, parseUnits } from "@/lib/solana/config";
+import {
+ explorerUrl,
+ formatUnits,
+ parseUnits,
+ requireRent,
+} from "@/lib/solana/config";
import type { SolanaPanelProps } from "./SolanaWorkspace";
import SolanaHistory from "./SolanaHistory";
+/**
+ * Slots a trade quote stays valid on-chain: longer than the blockhash (about 150 blocks), so the blockhash expiry the
+ * client checks always comes first, and a slow wallet approval never meets a raw program "Expired" error.
+ */
+const TRADE_EXPIRY_SLOTS = 300n;
+
export default function PoolPanel({
connection,
config,
@@ -63,24 +75,26 @@ export default function PoolPanel({
if (reading) return;
reading = true;
try {
- const result = await fetchPoolSnapshot(
+ // One call per poll: the pool, its custody, the wallet and its token account, all from one bank.
+ const owner = address ? new PublicKey(address) : null;
+ const { snapshot: result, extra } = await readPoolSnapshot(
connection,
program,
snapshot.address,
+ owner
+ ? [
+ owner,
+ deriveAssociatedTokenAddress(snapshot.addresses.mint, owner),
+ ]
+ : [],
);
if (!alive) return;
setFresh(result);
setLastRead(Date.now());
setQuoteError("");
- if (address) {
- const owner = new PublicKey(address);
- const [sol, token] = await Promise.all([
- connection.getBalance(owner, "confirmed"),
- connection.getAccountInfo(
- deriveAssociatedTokenAddress(result.addresses.mint, owner),
- "confirmed",
- ),
- ]);
+ if (owner && address) {
+ const [wallet, token] = extra;
+ const sol = wallet?.lamports ?? 0;
if (!Number.isSafeInteger(sol))
throw new Error(
"Balance precision is unsupported by this RPC client.",
@@ -140,11 +154,13 @@ export default function PoolPanel({
if (!address || working) return;
setWorking(true);
try {
- const current = await fetchPoolSnapshot(
- connection,
- program,
- snapshot.address,
- );
+ const trader = new PublicKey(address);
+ const {
+ snapshot: current,
+ extra: [ata],
+ } = await readPoolSnapshot(connection, program, snapshot.address, [
+ deriveAssociatedTokenAddress(snapshot.addresses.mint, trader),
+ ]);
if (current.pool.status !== "active")
throw new Error("This pool is not active.");
const amountIn = parseUnits(amount, side === "buy" ? 9 : 6);
@@ -153,8 +169,7 @@ export default function PoolPanel({
? quoteBuy(current.pool, amountIn)
: quoteSell(current.pool, amountIn);
const minimumOut = minimumAfterSlippage(quote.amountOut, 100);
- const trader = new PublicKey(address);
- const expirySlot = BigInt(await connection.getSlot("confirmed")) + 120n;
+ const expirySlot = BigInt(current.slot) + TRADE_EXPIRY_SLOTS;
const args = {
creator: current.pool.creator,
nonce: current.pool.nonce,
@@ -174,13 +189,9 @@ export default function PoolPanel({
buildBuyInstruction(program, args),
]
: [buildSellInstruction(program, args)];
- const ata = await connection.getAccountInfo(
- deriveAssociatedTokenAddress(current.addresses.mint, trader),
- "confirmed",
- );
const extraRent =
side === "buy" && !ata
- ? await connection.getMinimumBalanceForRentExemption(165)
+ ? requireRent(await connection.getMinimumBalanceForRentExemption(165))
: 0;
await actions.prepare(
side === "buy" ? "Buy tokens" : "Sell tokens",
@@ -207,6 +218,7 @@ export default function PoolPanel({
["Expiry slot", expirySlot.toString()],
],
snapshot.address.toBase58(),
+ expirySlot,
);
} catch (e) {
actions.setError(
@@ -236,8 +248,10 @@ export default function PoolPanel({
const rent = activate
? (
await Promise.all(
- [82, 165, 49, 49].map((space) =>
- connection.getMinimumBalanceForRentExemption(space),
+ [82, 165, 49, 49].map(async (space) =>
+ requireRent(
+ await connection.getMinimumBalanceForRentExemption(space),
+ ),
),
)
).reduce((sum, n) => sum + n, 0)
diff --git a/app/src/components/solana/SolanaHistory.tsx b/app/src/components/solana/SolanaHistory.tsx
index 24125541..384f6fd6 100644
--- a/app/src/components/solana/SolanaHistory.tsx
+++ b/app/src/components/solana/SolanaHistory.tsx
@@ -152,8 +152,8 @@ export default function SolanaHistory({
}
}
void load();
- // The API caches for 15 seconds. Retry even when no further swap changes
- // sequence, including after an initially unavailable RPC transaction.
+ // The API caches each pool and sequence for 30 seconds. Retry even when no
+ // further swap changes sequence, including after an initially unavailable RPC transaction.
const timer = setInterval(() => void load(), 20_000);
document.addEventListener("visibilitychange", load);
return () => {
@@ -168,7 +168,8 @@ export default function SolanaHistory({
SOL per token · 5-minute candles · Confirmed program events
- {error ? (
+ {/* A failed refresh keeps the last loaded trades on screen; the error replaces nothing it can't. */}
+ {error && !result ? (
{error}
@@ -198,6 +199,9 @@ export default function SolanaHistory({
{result.coverage.unavailableTransactions > 0
? " Some transactions are unavailable from the RPC."
: ""}
+ {error
+ ? " The latest refresh failed, so this shows the last trades loaded."
+ : ""}
{result.trades
diff --git a/app/src/components/solana/SolanaWorkspace.tsx b/app/src/components/solana/SolanaWorkspace.tsx
index 324f502d..1b134527 100644
--- a/app/src/components/solana/SolanaWorkspace.tsx
+++ b/app/src/components/solana/SolanaWorkspace.tsx
@@ -13,8 +13,7 @@ import {
Wallet,
} from "lucide-react";
import {
- decodePoolAccount,
- encodeBase58,
+ fetchActivePools,
fetchPoolSnapshot,
verifyImmutableProgram,
type PoolSnapshot,
@@ -121,39 +120,21 @@ export default function SolanaWorkspace({
);
if (alive) setSnapshot(result);
} else {
- const digest = new Uint8Array(
- await crypto.subtle.digest(
- "SHA-256",
- new TextEncoder().encode("account:Pool"),
- ),
+ // Active pools only, addresses first: tombstoned pools never travel, so the list cannot outgrow the proxy.
+ const { total, pools } = await fetchActivePools(
+ connection,
+ programId,
+ 50,
);
- const rows = await connection.getProgramAccounts(programId, {
- commitment: "confirmed",
- filters: [
- {
- memcmp: { offset: 0, bytes: encodeBase58(digest.slice(0, 8)) },
- },
- ],
- });
- const active = rows.flatMap((row) => {
- try {
- const p = decodePoolAccount(row.pubkey, row.account, programId);
- return p.status === "active"
- ? [
- {
- key: row.pubkey.toBase58(),
- name: p.name,
- symbol: p.symbol,
- },
- ]
- : [];
- } catch {
- return [];
- }
- });
if (alive) {
- setList(active.slice(0, 50));
- setListTotal(active.length);
+ setList(
+ pools.map(({ address, pool }) => ({
+ key: address.toBase58(),
+ name: pool.name,
+ symbol: pool.symbol,
+ })),
+ );
+ setListTotal(total);
}
}
} catch (e) {
diff --git a/app/src/lib/solana/config.ts b/app/src/lib/solana/config.ts
index ee8b0128..d9f8ef6e 100644
--- a/app/src/lib/solana/config.ts
+++ b/app/src/lib/solana/config.ts
@@ -100,6 +100,16 @@ export function parseUnits(value: string, decimals: number): bigint {
return amount;
}
+/**
+ * web3.js answers a JSON-RPC error from getMinimumBalanceForRentExemption with 0, which a review would show as free
+ * rent. Rent is never zero, so treat 0 as a failed read.
+ */
+export function requireRent(lamports: number): number {
+ if (!Number.isSafeInteger(lamports) || lamports <= 0)
+ throw new Error("Could not read account rent from Solana RPC. Try again.");
+ return lamports;
+}
+
export function formatUnits(value: bigint, decimals: number): string {
const negative = value < 0n;
const digits = (negative ? -value : value)
diff --git a/app/src/lib/solana/history.test.ts b/app/src/lib/solana/history.test.ts
index cb5c3324..9d8dc0cd 100644
--- a/app/src/lib/solana/history.test.ts
+++ b/app/src/lib/solana/history.test.ts
@@ -384,7 +384,11 @@ test("RPC history scanning is bounded, filters failed signatures and reports una
test("cache deduplicates requests and limits concurrent and per-minute upstream starts", async () => {
let now = 60_001;
- const cached = createHistoryCache(() => now);
+ const cached = createHistoryCache(() => now, {
+ maxPending: 2,
+ startsPerMinute: 6,
+ ttlMs: 15_000,
+ });
let resolve: (n: number) => void = () => {};
let starts = 0;
const loader = () => {
@@ -415,3 +419,41 @@ test("cache deduplicates requests and limits concurrent and per-minute upstream
now += 60_000;
assert.equal(await cached("g", async () => 7), 7);
});
+
+test("refreshes reuse confirmed transactions and refetch a signature the listing moved to another slot", async () => {
+ const logs = [
+ invoke(other),
+ invoke(program, 2),
+ emit(first.bytes),
+ success(program),
+ success(other),
+ ];
+ let calls = 0;
+ let listedSlot = 50;
+ const fake = {
+ getSignaturesForAddress: async () => [
+ {
+ signature: "signature",
+ slot: listedSlot,
+ err: null,
+ memo: null,
+ confirmationStatus: "confirmed",
+ },
+ ],
+ getTransaction: async () => {
+ calls++;
+ return transaction(logs, { slot: listedSlot });
+ },
+ } as unknown as Connection;
+ const transactions = new Map();
+ const a = await fetchRecentSolanaHistory(fake, program, snapshot(), transactions);
+ const b = await fetchRecentSolanaHistory(fake, program, snapshot(), transactions);
+ assert.equal(a.tradeCount, 1);
+ assert.equal(b.tradeCount, 1);
+ assert.equal(calls, 1);
+ // The listing now places the signature at another slot: the cached copy is not trusted, the RPC is asked again.
+ listedSlot = 49;
+ await fetchRecentSolanaHistory(fake, program, snapshot(), transactions);
+ assert.equal(calls, 2);
+ assert.equal(transactions.get("signature")?.slot, 49);
+});
diff --git a/app/src/lib/solana/history.ts b/app/src/lib/solana/history.ts
index 6b64097c..c6a3c0c7 100644
--- a/app/src/lib/solana/history.ts
+++ b/app/src/lib/solana/history.ts
@@ -374,10 +374,16 @@ export function recentHistoryFromEvents(
};
}
+/** Confirmed transactions by signature, re-parsed against each new snapshot: a refresh fetches only the new ones. */
+export type TransactionCache = Map;
+const TRANSACTION_CACHE_LIMIT = 1_000;
+const transactionCache: TransactionCache = new Map();
+
export async function fetchRecentSolanaHistory(
connection: Connection,
programId: PublicKey,
snapshot: PoolSnapshot,
+ transactions: TransactionCache = transactionCache,
): Promise {
const signatures = (
await connection.getSignaturesForAddress(
@@ -413,14 +419,25 @@ export async function fetchRecentSolanaHistory(
}
const signature = candidates[next++];
try {
- const tx = await connection.getTransaction(signature.signature, {
- commitment: "confirmed",
- maxSupportedTransactionVersion: 0,
- });
+ // A cached copy counts only at the slot the listing reports: a fork can move or drop a confirmed signature.
+ const known = transactions.get(signature.signature);
+ const tx =
+ known && known.slot === signature.slot
+ ? known
+ : await connection.getTransaction(signature.signature, {
+ commitment: "confirmed",
+ maxSupportedTransactionVersion: 0,
+ });
if (!tx) {
unavailableTransactions++;
continue;
}
+ if (tx !== known) {
+ transactions.delete(signature.signature);
+ transactions.set(signature.signature, tx);
+ if (transactions.size > TRANSACTION_CACHE_LIMIT)
+ transactions.delete(transactions.keys().next().value!);
+ }
const parsed = parseSolanaTransaction(
tx,
signature.signature,
@@ -456,8 +473,16 @@ export async function fetchRecentSolanaHistory(
};
}
-/** Per-process load shedding, not a distributed quota or durable indexer. */
-export function createHistoryCache(now: () => number = Date.now) {
+/**
+ * Per-process load shedding, not a distributed quota or durable indexer. Callers charge it only for pools that exist
+ * (the route checks the snapshot first) and key it by pool and sequence, so made-up addresses cost nothing here and a
+ * pool's result is reused until it trades again or the TTL passes. Loads are incremental (`TransactionCache`), so the
+ * limits are set for many watched pools, not for full 100-transaction rescans.
+ */
+export function createHistoryCache(
+ now: () => number = Date.now,
+ { maxPending = 4, startsPerMinute = 30, ttlMs = 30_000 } = {},
+) {
const cache = new Map();
const pending = new Map>();
let windowAt = 0;
@@ -472,7 +497,8 @@ export function createHistoryCache(now: () => number = Date.now) {
windowAt = time;
starts = 0;
}
- if (pending.size >= 2 || starts >= 6) throw new Error("HISTORY_BUSY");
+ if (pending.size >= maxPending || starts >= startsPerMinute)
+ throw new Error("HISTORY_BUSY");
starts++;
const task = Promise.resolve()
.then(load)
@@ -480,7 +506,7 @@ export function createHistoryCache(now: () => number = Date.now) {
for (const [id, entry] of cache)
if (entry.expires <= now()) cache.delete(id);
if (cache.size >= 128) cache.delete(cache.keys().next().value!);
- cache.set(key, { expires: now() + 15_000, value });
+ cache.set(key, { expires: now() + ttlMs, value });
return value;
})
.finally(() => pending.delete(key));
diff --git a/app/src/lib/solana/rpc-errors.test.ts b/app/src/lib/solana/rpc-errors.test.ts
new file mode 100644
index 00000000..a8c42cf6
--- /dev/null
+++ b/app/src/lib/solana/rpc-errors.test.ts
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { solanaRpcError } from "./rpc-errors.ts";
+
+test("a refused send keeps its code, transaction error and program logs, not provider text", () => {
+ const out = solanaRpcError(
+ {
+ code: -32002,
+ message: "Transaction simulation failed: Error processing Instruction 1 (provider acme-pro-plan key ab12)",
+ data: {
+ err: { InstructionError: [1, { Custom: 6010 }] },
+ logs: ["Program log: AnchorError occurred. Error Code: Slippage. Error Number: 6010. Error Message: Minimum output not met."],
+ accounts: null,
+ },
+ },
+ "sendTransaction",
+ );
+ assert.equal(out.code, -32002);
+ assert.equal(out.message, 'Transaction simulation failed: {"InstructionError":[1,{"Custom":6010}]}');
+ assert.equal(out.message.includes("acme"), false);
+ assert.deepEqual(out.data?.err, { InstructionError: [1, { Custom: 6010 }] });
+ assert.equal(out.data?.logs.length, 1);
+});
+
+test("enum errors stay readable and AlreadyProcessed survives for the client to reconcile", () => {
+ const out = solanaRpcError(
+ { code: -32002, message: "x", data: { err: "AlreadyProcessed", logs: [] } },
+ "sendTransaction",
+ );
+ assert.equal(out.message, "Transaction simulation failed: AlreadyProcessed");
+});
+
+test("reads get only a fixed message; unknown codes and malformed errors fall back", () => {
+ assert.deepEqual(solanaRpcError({ code: -32005, message: "node 10.0.0.7 behind", data: { err: "x" } }, "getAccountInfo"), {
+ code: -32005,
+ message: "Solana RPC node is behind; retry shortly",
+ });
+ assert.equal(solanaRpcError("boom", "getSlot").code, -32000);
+ assert.equal(solanaRpcError({ code: 1.5 }, "getSlot").code, -32000);
+});
+
+test("logs and oversized transaction errors are bounded", () => {
+ const out = solanaRpcError(
+ {
+ code: -32002,
+ data: { err: { InstructionError: [0, "x".repeat(600)] }, logs: [...Array(100).keys()].map((i) => `${i} ${"y".repeat(400)}`) },
+ },
+ "sendTransaction",
+ );
+ assert.equal(out.data?.err, null);
+ assert.equal(out.message, "Transaction simulation failed");
+ assert.equal(out.data?.logs.length, 60);
+ assert(out.data!.logs.every((line) => line.length <= 300));
+});
diff --git a/app/src/lib/solana/rpc-errors.ts b/app/src/lib/solana/rpc-errors.ts
new file mode 100644
index 00000000..64901ef7
--- /dev/null
+++ b/app/src/lib/solana/rpc-errors.ts
@@ -0,0 +1,48 @@
+/** Fixed text per JSON-RPC code: provider wording (which can name plans, keys or internal hosts) never reaches the page. */
+const MESSAGES: Record = {
+ [-32002]: "Transaction simulation failed",
+ [-32003]: "Transaction signature verification failed",
+ [-32004]: "Block not available for this slot",
+ [-32005]: "Solana RPC node is behind; retry shortly",
+ [-32007]: "Slot was skipped or is missing",
+ [-32009]: "Slot was skipped or is missing",
+ [-32014]: "Block status not yet available",
+ [-32016]: "Minimum context slot has not been reached",
+ [-32602]: "Invalid parameters",
+ [429]: "Solana RPC is busy; retry shortly",
+};
+const FALLBACK = "Solana RPC rejected this request. Check the transaction status before retrying.";
+
+/**
+ * A sanitized JSON-RPC error that still says why a send failed: the numeric code, and for sends and simulations the
+ * structured transaction error and the program logs (bounded). A refused preflight is a definite "not sent", and the
+ * client can only tell that, and show the program's reason, if the code and logs survive the proxy.
+ */
+export function solanaRpcError(
+ error: unknown,
+ method: string,
+): { code: number; message: string; data?: { err: unknown; logs: string[] } } {
+ const e = (error && typeof error === "object" ? error : {}) as {
+ code?: unknown;
+ data?: unknown;
+ };
+ const code = typeof e.code === "number" && Number.isSafeInteger(e.code) ? e.code : -32000;
+ let message = MESSAGES[code] ?? FALLBACK;
+ if (method !== "sendTransaction" && method !== "simulateTransaction")
+ return { code, message };
+ const data = (e.data && typeof e.data === "object" ? e.data : {}) as {
+ err?: unknown;
+ logs?: unknown;
+ };
+ const text = data.err === undefined ? "" : JSON.stringify(data.err);
+ // TransactionError is an enum name or a small object such as {"InstructionError":[1,{"Custom":6010}]}.
+ const err = text && text.length <= 512 ? data.err : null;
+ if (err !== null) message = `${message}: ${typeof err === "string" ? err : text}`;
+ const logs = Array.isArray(data.logs)
+ ? data.logs
+ .filter((line): line is string => typeof line === "string")
+ .slice(-60)
+ .map((line) => line.slice(0, 300))
+ : [];
+ return { code, message, data: { err, logs } };
+}
diff --git a/app/src/lib/solana/rpc-policy.test.ts b/app/src/lib/solana/rpc-policy.test.ts
index d1460dc4..ad40251d 100644
--- a/app/src/lib/solana/rpc-policy.test.ts
+++ b/app/src/lib/solana/rpc-policy.test.ts
@@ -144,3 +144,13 @@ test("RPC sends only fully signed program transactions; simulation never signs",
false,
);
});
+
+test("RPC allows the epoch read that pairs a slot with its block height", () => {
+ assert.equal(
+ allowedSolanaRpc(
+ request("getEpochInfo", [{ commitment: "finalized" }]),
+ program.toBase58(),
+ ),
+ true,
+ );
+});
diff --git a/app/src/lib/solana/rpc-policy.ts b/app/src/lib/solana/rpc-policy.ts
index 7ce6a10d..85d18ee3 100644
--- a/app/src/lib/solana/rpc-policy.ts
+++ b/app/src/lib/solana/rpc-policy.ts
@@ -8,6 +8,7 @@ const READS = new Set([
"getBalance",
"getLatestBlockhash",
"getBlockHeight",
+ "getEpochInfo",
"getSlot",
"getGenesisHash",
"getFeeForMessage",
diff --git a/app/src/lib/solana/same-origin.test.ts b/app/src/lib/solana/same-origin.test.ts
new file mode 100644
index 00000000..8596f295
--- /dev/null
+++ b/app/src/lib/solana/same-origin.test.ts
@@ -0,0 +1,57 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { sameOriginRequest } from "./same-origin.ts";
+
+// The standalone server builds route URLs from its bind address, never the public host.
+const request = (headers: Record) =>
+ new Request("http://0.0.0.0:3000/api/solana/rpc", { method: "POST", headers });
+
+test("a browser call from the site passes although the route URL is the bind address", () => {
+ assert.equal(
+ sameOriginRequest(request({ origin: "https://openlaunch.lol", host: "openlaunch.lol" })),
+ true,
+ );
+ assert.equal(
+ sameOriginRequest(
+ request({
+ origin: "https://openlaunch.lol",
+ host: "0.0.0.0:3000",
+ "x-forwarded-host": "openlaunch.lol",
+ }),
+ ),
+ true,
+ );
+ assert.equal(
+ sameOriginRequest(request({ origin: "http://localhost:3000", host: "localhost:3000" })),
+ true,
+ );
+ assert.equal(
+ sameOriginRequest(request({ origin: "https://OpenLaunch.lol", host: "openlaunch.lol" })),
+ true,
+ );
+});
+
+test("other sites, opaque origins and ports are refused", () => {
+ assert.equal(
+ sameOriginRequest(request({ origin: "https://evil.example", host: "openlaunch.lol" })),
+ false,
+ );
+ assert.equal(
+ sameOriginRequest(request({ origin: "https://openlaunch.lol.evil.example", host: "openlaunch.lol" })),
+ false,
+ );
+ assert.equal(sameOriginRequest(request({ origin: "null", host: "openlaunch.lol" })), false);
+ assert.equal(
+ sameOriginRequest(request({ origin: "https://openlaunch.lol:8443", host: "openlaunch.lol" })),
+ false,
+ );
+ assert.equal(sameOriginRequest(request({ origin: "https://openlaunch.lol" })), false);
+});
+
+test("a request without Origin passes unless the browser marks it cross-site", () => {
+ assert.equal(sameOriginRequest(request({ host: "openlaunch.lol" })), true);
+ assert.equal(
+ sameOriginRequest(request({ host: "openlaunch.lol", "sec-fetch-site": "cross-site" })),
+ false,
+ );
+});
diff --git a/app/src/lib/solana/same-origin.ts b/app/src/lib/solana/same-origin.ts
new file mode 100644
index 00000000..3ddd53b5
--- /dev/null
+++ b/app/src/lib/solana/same-origin.ts
@@ -0,0 +1,25 @@
+/**
+ * Same-origin check for browser calls to the Solana routes. The request URL cannot be compared: under the standalone
+ * server it is the bind address (http://0.0.0.0:3000), not the public host. Compare the Origin's host with the host the
+ * browser addressed (the edge's X-Forwarded-Host, else Host), as Next does for Server Actions. A page on another site
+ * cannot set either header without a CORS preflight, which these routes never grant. A request with no Origin is not
+ * a cross-site browser request (browsers always send Origin on POST) and is left to the rate limits.
+ */
+export function sameOriginRequest(req: Request): boolean {
+ const origin = req.headers.get("origin");
+ if (!origin) return req.headers.get("sec-fetch-site") !== "cross-site";
+ let host: string;
+ try {
+ host = new URL(origin).host.toLowerCase();
+ } catch {
+ return false;
+ }
+ const addressed = (
+ req.headers.get("x-forwarded-host")?.split(",")[0] ??
+ req.headers.get("host") ??
+ ""
+ )
+ .trim()
+ .toLowerCase();
+ return addressed !== "" && host === addressed;
+}
diff --git a/app/src/lib/solana/useSolanaAction.ts b/app/src/lib/solana/useSolanaAction.ts
index 11b38b49..aba33cb6 100644
--- a/app/src/lib/solana/useSolanaAction.ts
+++ b/app/src/lib/solana/useSolanaAction.ts
@@ -18,6 +18,7 @@ import {
simulatePreparedTransaction,
validateSignedTransaction,
submitSignedTransaction,
+ rebroadcastSignedTransaction,
reconcileTransaction,
verifyImmutableProgram,
type PreparedTransaction,
@@ -44,7 +45,13 @@ export type ActionReview = {
payer: string;
pool: string | null;
networkFee: number;
+ /** The program's own deadline for a trade (its expiry slot), checked with the blockhash before signing. */
+ expirySlot: bigint | null;
};
+/** Slots of headroom a trade review keeps before its expiry slot: about 8 seconds to approve in the wallet. */
+const EXPIRY_MARGIN_SLOTS = 20n;
+/** While a signature is unseen, its identical bytes are sent again at most this often. */
+const REBROADCAST_MS = 8_000;
type Recovery = {
context: ActionContext;
pending: PendingSolanaAction | null;
@@ -78,6 +85,8 @@ export function useSolanaAction(
const [busy, setBusy] = useState(false);
const gate = useRef(false);
const checks = useRef(new Set());
+ // The signed bytes of this tab's latest send, kept in memory only, so a dropped packet can be sent again.
+ const sent = useRef<{ signature: string; bytes: Uint8Array; at: number } | null>(null);
const currentContext = useRef(context);
const currentScope = useRef(scope);
const settled = useRef(onSettled);
@@ -214,7 +223,17 @@ export function useSolanaAction(
pending.lastValidBlockHeight,
);
if (!live()) return;
+ const bytes = sent.current;
+ if (
+ result.unseen &&
+ bytes?.signature === pending.signature &&
+ Date.now() - bytes.at >= REBROADCAST_MS
+ ) {
+ bytes.at = Date.now();
+ void rebroadcastSignedTransaction(connection, bytes.bytes);
+ }
if (["finalized", "failed", "expired"].includes(result.status)) {
+ if (sent.current?.signature === pending.signature) sent.current = null;
if (!navigator.locks) throw new Error("Web Locks unavailable");
await navigator.locks.request(key, { ifAvailable: true }, (lock) => {
if (!lock || !live()) return;
@@ -283,6 +302,7 @@ export function useSolanaAction(
instructions: TransactionInstruction[],
lines: [string, string][],
pool: string | null,
+ expirySlot: bigint | null = null,
) {
if (
gate.current ||
@@ -356,6 +376,7 @@ export function useSolanaAction(
payer: address,
pool,
networkFee: fee.value,
+ expirySlot,
},
});
} catch (e) {
@@ -404,9 +425,15 @@ export function useSolanaAction(
throw new Error(
"The wallet or selected pool changed. Review again.",
);
+ // One bank gives both deadlines: the blockhash's block height and the trade's own expiry slot.
+ const epoch = await connection.getEpochInfo("confirmed");
+ const height =
+ epoch.blockHeight ?? (await connection.getBlockHeight("confirmed"));
if (
- (await connection.getBlockHeight("confirmed")) >
- review.prepared.lastValidBlockHeight
+ height > review.prepared.lastValidBlockHeight ||
+ (review.expirySlot !== null &&
+ BigInt(epoch.absoluteSlot) + EXPIRY_MARGIN_SLOTS >
+ review.expirySlot)
)
throw new Error("This review expired. Refresh it before signing.");
if (!reviewLive())
@@ -443,7 +470,28 @@ export function useSolanaAction(
persistJournalForSession(localStorage, key, record, reviewLive);
setRecovery({ context, pending: record, ready: true, error: "" });
setReview(null);
+ sent.current = {
+ signature: signed.signature,
+ bytes: signed.bytes,
+ at: Date.now(),
+ };
const result = await submitSignedTransaction(connection, signed);
+ if (result.status === "rejected") {
+ // Refused before it was forwarded, so these signed bytes can never land: release the wallet (we still
+ // hold its lock) and say why, instead of waiting out the blockhash as an uncertain submission.
+ sent.current = null;
+ clearJournalIfCurrent(localStorage, key, record);
+ setRecovery((old) =>
+ old &&
+ sameActionContext(old.context, context) &&
+ samePendingAction(old.pending, record)
+ ? { ...old, pending: null, error: "" }
+ : old,
+ );
+ throw new Error(
+ `${result.error ?? "Solana RPC refused the transaction"}. Nothing was sent. Review again to retry.`,
+ );
+ }
if (live())
setMessage(
result.status === "unknown"
diff --git a/docs/SOLANA_RELEASE.md b/docs/SOLANA_RELEASE.md
index 0c990828..bc8b50fa 100644
--- a/docs/SOLANA_RELEASE.md
+++ b/docs/SOLANA_RELEASE.md
@@ -47,7 +47,7 @@ For app inspection with default configuration, run `npm run dev` in `app/` and v
## Required product and operations work before broad mainnet rollout
-- [ ] Durable paginated discovery and history. Current discovery reads all program pool accounts and displays at most 50; the RPC response cap is 4 MiB. Recent history reads at most 100 signatures. Neither is a production-scale indexer.
+- [ ] Durable paginated discovery and history. Current discovery lists active pools only (status-byte filter, addresses without data, so roughly 18,000 fit under the 4 MiB RPC response cap) and reads data for at most 50, in address order rather than by activity. Recent history reads at most 100 signatures, refetching only transactions it has not already cached. Neither is a production-scale indexer.
- [ ] Finalized checkpoint/reorg recovery, backfill and independent account reconciliation; full holder/transfer indexing before showing holder counts, lifetime volume or complete history.
- [ ] RPC provider quotas, edge-level rate limiting and monitoring. Current per-IP and process-wide budgets are a backstop, not a distributed abuse-control system. Account-provider outages must not cause automatic financial retries.
- [ ] Approve immutable metadata hosting/hash policy. Canonical name/symbol/URI live in the pool; no Metaplex metadata mirror is supplied, so third-party wallets may show the mint address rather than branded token metadata.
From 1a29c59c32f7972274e968eb9f1d8b33c3f5852b Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 17:16:58 +0530
Subject: [PATCH 7/9] fix(solana-sdk): read Anchor error messages without a
backtracking regex
CodeQL js/polynomial-redos (alert #17): describeTransactionError matched
program logs with /Error Message: (.+?)\.?$/, which can run in quadratic
time on a long line repeating "Error Message: a". Logs are untrusted
input, so the message is now found with indexOf and a slice. Same result
for real Anchor logs; an empty message still falls back to the error code.
---
app/packages/solana-sdk/src/transactions.ts | 9 +++++++--
app/packages/solana-sdk/tests/transactions.test.ts | 6 ++++++
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/app/packages/solana-sdk/src/transactions.ts b/app/packages/solana-sdk/src/transactions.ts
index 23ea3daf..219f10e9 100644
--- a/app/packages/solana-sdk/src/transactions.ts
+++ b/app/packages/solana-sdk/src/transactions.ts
@@ -12,9 +12,14 @@ const POOL_ERRORS = [
];
/** A readable reason for a failed simulation or a refused send: the program's own error message when there is one. */
export function describeTransactionError(err: unknown, logs: readonly string[] = []): string {
+ // Anchor logs end "... Error Message: ." Found by index, not regex: logs are untrusted input (CodeQL js/polynomial-redos).
+ const marker = "Error Message: ";
for (const line of logs) {
- const anchor = /Error Message: (.+?)\.?$/.exec(line);
- if (anchor) return anchor[1];
+ const at = line.indexOf(marker);
+ if (at === -1) continue;
+ const text = line.slice(at + marker.length).trim();
+ const message = text.endsWith(".") ? text.slice(0, -1) : text;
+ if (message) return message;
}
const text = typeof err === "string" ? err : JSON.stringify(err) ?? "";
const custom = /"Custom":(\d+)/.exec(text);
diff --git a/app/packages/solana-sdk/tests/transactions.test.ts b/app/packages/solana-sdk/tests/transactions.test.ts
index 709cdc99..159b89ae 100644
--- a/app/packages/solana-sdk/tests/transactions.test.ts
+++ b/app/packages/solana-sdk/tests/transactions.test.ts
@@ -53,6 +53,12 @@ test("transaction errors read as the program's messages", () => {
assert.equal(describeTransactionError({ InstructionError: [0, { Custom: 1 }] }), "Program error 1");
assert.equal(describeTransactionError("InsufficientFundsForFee"), "InsufficientFundsForFee");
assert.equal(describeTransactionError({ InstructionError: [0, { Custom: 6010 }] }, ["Program log: AnchorError occurred. Error Code: Slippage. Error Number: 6010. Error Message: Minimum output not met."]), "Minimum output not met");
+ // An empty message falls through to the error code; a long hostile line is read in one pass.
+ assert.equal(describeTransactionError({ InstructionError: [0, { Custom: 6010 }] }, ["Program log: Error Message: ."]), "Minimum output not met");
+ const hostile = `Program log: ${"Error Message: a".repeat(20_000)}`;
+ const started = performance.now();
+ assert.equal(describeTransactionError("x", [hostile]).startsWith("aError Message: "), true);
+ assert(performance.now() - started < 50);
});
test("reconciliation needs finalized expiry or finalized failure before safe replacement", async () => {
let height = 99;
From bc79d5cbc4bbf1f4fd8c4e16e93d35430a3edb6d Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 19:59:27 +0530
Subject: [PATCH 8/9] fix(solana): address CodeRabbit review on #86
- CI: the solana job's checkout sets persist-credentials: false. No step
uses the token, so it no longer sits in .git/config while cargo install
and the build tools run.
- Release manifest: validateDeploymentManifest rejects a genesis hash that
does not belong to the declared cluster (CLUSTER_GENESIS), so
verify-manifest cannot report devnet evidence as mainnet.
- History panel: the fetch signal is linked by hand (linkedTimeoutSignal,
the bridge's existing helper) instead of AbortSignal.any, which older
wallet in-app browsers lack, so history loads there too.
---
.github/workflows/ci.yml | 3 +++
app/packages/solana-sdk/src/verification.ts | 6 ++++++
app/packages/solana-sdk/tests/verification.test.ts | 8 ++++++--
app/src/components/solana/SolanaHistory.tsx | 7 +++----
4 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b21eb85b..25daffb7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,6 +53,9 @@ jobs:
working-directory: solana
steps:
- uses: actions/checkout@v4
+ with:
+ # Nothing here pushes or fetches with the token; keep it out of .git/config for cargo install and build tools.
+ persist-credentials: false
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.97.1
diff --git a/app/packages/solana-sdk/src/verification.ts b/app/packages/solana-sdk/src/verification.ts
index 3c476909..6c56c6bf 100644
--- a/app/packages/solana-sdk/src/verification.ts
+++ b/app/packages/solana-sdk/src/verification.ts
@@ -8,6 +8,11 @@ export type DeploymentManifest = {
genesisHash: string; sourceCommit: string; binarySha256: string;
verifiedSlot: number | null; securityReview: string | null; economicReview: string | null; releaseApproved: boolean;
};
+/** Each supported cluster's genesis hash: release evidence must name the cluster it was actually checked on. */
+export const CLUSTER_GENESIS = {
+ "mainnet-beta": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
+ devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
+} as const;
export function validateDeploymentManifest(input: unknown): DeploymentManifest {
if (!input || typeof input !== "object") throw new Error("Invalid manifest");
const m = input as Record;
@@ -16,6 +21,7 @@ export function validateDeploymentManifest(input: unknown): DeploymentManifest {
if (program.equals(PublicKey.default) || program.toBase58() === "Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr") throw new Error("Test fixture is not a deployment identity");
if (!programDataAddress(program).equals(new PublicKey(m.programData))) throw new Error("Manifest ProgramData mismatch");
if (new PublicKey(m.genesisHash).toBase58() !== m.genesisHash) throw new Error("Invalid genesis hash");
+ if (m.genesisHash !== CLUSTER_GENESIS[m.cluster as keyof typeof CLUSTER_GENESIS]) throw new Error("Genesis hash does not match the declared cluster");
if (m.verifiedSlot !== null && (!Number.isSafeInteger(m.verifiedSlot) || Number(m.verifiedSlot) < 0)) throw new Error("Invalid verified slot");
if (typeof m.releaseApproved !== "boolean" || (m.securityReview !== null && typeof m.securityReview !== "string") || (m.economicReview !== null && typeof m.economicReview !== "string")) throw new Error("Invalid review metadata");
if (m.releaseApproved && (!m.securityReview || !m.economicReview || !m.verifiedSlot)) throw new Error("Release approval requires review evidence");
diff --git a/app/packages/solana-sdk/tests/verification.test.ts b/app/packages/solana-sdk/tests/verification.test.ts
index ddef3eeb..651afab6 100644
--- a/app/packages/solana-sdk/tests/verification.test.ts
+++ b/app/packages/solana-sdk/tests/verification.test.ts
@@ -4,16 +4,20 @@ import { Buffer } from "buffer";
import { sha256 } from "@noble/hashes/sha256";
import { PublicKey } from "@solana/web3.js";
import { programDataAddress } from "../src/accounts.ts";
-import { validateDeploymentManifest, verifyDeploymentBytes } from "../src/verification.ts";
+import { CLUSTER_GENESIS, validateDeploymentManifest, verifyDeploymentBytes } from "../src/verification.ts";
test("release identity must be explicit, not compile fixture or unreviewed defaults", () => {
const programId = new PublicKey(new Uint8Array(32).fill(7));
- const manifest = { schema: 1, cluster: "devnet", programId: programId.toBase58(), programData: programDataAddress(programId).toBase58(), genesisHash: new PublicKey(new Uint8Array(32).fill(8)).toBase58(), sourceCommit: "a".repeat(40), binarySha256: "b".repeat(64), verifiedSlot: null, securityReview: null, economicReview: null, releaseApproved: false };
+ const manifest = { schema: 1, cluster: "devnet", programId: programId.toBase58(), programData: programDataAddress(programId).toBase58(), genesisHash: CLUSTER_GENESIS.devnet, sourceCommit: "a".repeat(40), binarySha256: "b".repeat(64), verifiedSlot: null, securityReview: null, economicReview: null, releaseApproved: false };
assert.equal(validateDeploymentManifest(manifest).releaseApproved, false);
assert.throws(() => validateDeploymentManifest({ ...manifest, releaseApproved: true }), /review evidence/);
assert.throws(() => validateDeploymentManifest({ ...manifest, programId: "Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr" }), /fixture/);
assert.throws(() => validateDeploymentManifest({ ...manifest, genesisHash: null }));
assert.throws(() => validateDeploymentManifest({ ...manifest, programData: programId.toBase58() }));
+ // The declared cluster and the genesis hash must agree, or verify-manifest would label one cluster's evidence as another's.
+ assert.throws(() => validateDeploymentManifest({ ...manifest, cluster: "mainnet-beta" }), /declared cluster/);
+ assert.throws(() => validateDeploymentManifest({ ...manifest, genesisHash: new PublicKey(new Uint8Array(32).fill(8)).toBase58() }), /declared cluster/);
+ assert.equal(validateDeploymentManifest({ ...manifest, cluster: "mainnet-beta", genesisHash: CLUSTER_GENESIS["mainnet-beta"] }).cluster, "mainnet-beta");
});
test("deployed ELF requires exact reviewed hash/bytes and only zero allocation padding", () => {
const elf = Buffer.alloc(100); elf.set([0x7f, 0x45, 0x4c, 0x46]);
diff --git a/app/src/components/solana/SolanaHistory.tsx b/app/src/components/solana/SolanaHistory.tsx
index 384f6fd6..b4c1acc2 100644
--- a/app/src/components/solana/SolanaHistory.tsx
+++ b/app/src/components/solana/SolanaHistory.tsx
@@ -14,6 +14,7 @@ import {
type SolanaConfig,
} from "@/lib/solana/config";
import type { SolanaHistory as SolanaHistoryResponse } from "@/lib/solana/history";
+import { linkedTimeoutSignal } from "@/lib/bridge/client";
function Candles({ candles }: { candles: SolanaHistoryResponse["candles"] }) {
const box = useRef(null);
@@ -121,10 +122,8 @@ export default function SolanaHistory({
const response = await fetch(
`/api/solana/history/${encodeURIComponent(pool)}`,
{
- signal: AbortSignal.any([
- abort.signal,
- AbortSignal.timeout(20_000),
- ]),
+ // Not AbortSignal.any: older wallet in-app browsers lack it, and history would never load there.
+ signal: linkedTimeoutSignal(abort.signal, 20_000),
cache: "no-store",
},
);
From e06d8e986a86caa2365891eea690b71bb991211d Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 20:07:38 +0530
Subject: [PATCH 9/9] fix(bridge): detach linkedTimeoutSignal from its parent
once it aborts
From the CodeRabbit review on #86. The parent abort listener was removed
only when the parent aborted, so each request that ended by timeout left a
listener on a long-lived parent (the history panel's effect controller
gains one per refresh). The listener now detaches when the linked signal
aborts, timeout included; a parent abort still propagates.
---
app/src/lib/bridge/client.test.ts | 13 ++++++++++++-
app/src/lib/bridge/client.ts | 7 ++++++-
2 files changed, 18 insertions(+), 2 deletions(-)
diff --git a/app/src/lib/bridge/client.test.ts b/app/src/lib/bridge/client.test.ts
index 767e8be5..5cf14b33 100644
--- a/app/src/lib/bridge/client.test.ts
+++ b/app/src/lib/bridge/client.test.ts
@@ -549,10 +549,21 @@ test("linked timeout signal aborts on the parent, on the timer, and never needs
parent.abort(new Error("gone"));
assert.equal(signal.aborted, true);
assert.equal((signal.reason as Error).message, "gone");
- const timed = linkedTimeoutSignal(new AbortController().signal, 5);
+ // A timeout also detaches from the parent, so a long-lived parent never collects one listener per request.
+ const owner = new AbortController();
+ const detached: unknown[] = [];
+ const remove = owner.signal.removeEventListener.bind(owner.signal);
+ owner.signal.removeEventListener = ((type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) => {
+ detached.push(listener);
+ remove(type, listener, options);
+ }) as typeof owner.signal.removeEventListener;
+ const timed = linkedTimeoutSignal(owner.signal, 5);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(timed.aborted, true);
assert.equal((timed.reason as DOMException).name, "TimeoutError");
+ assert.equal(detached.length, 1);
+ owner.abort(new Error("later"));
+ assert.equal((timed.reason as DOMException).name, "TimeoutError");
const already = new AbortController();
already.abort();
assert.equal(linkedTimeoutSignal(already.signal, 60_000).aborted, true);
diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts
index 33418c5f..621f1a9c 100644
--- a/app/src/lib/bridge/client.ts
+++ b/app/src/lib/bridge/client.ts
@@ -185,7 +185,12 @@ export function linkedTimeoutSignal(parent: AbortSignal | undefined, timeoutMs:
const timer = setTimeout(() => controller.abort(new DOMException("The request timed out.", "TimeoutError")), timeoutMs);
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
if (parent?.aborted) controller.abort(parent.reason);
- else parent?.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
+ else if (parent) {
+ // Detach once this signal is done (a timeout included), so a long-lived parent does not collect a listener per request.
+ const onParentAbort = () => controller.abort(parent.reason);
+ parent.addEventListener("abort", onParentAbort, { once: true });
+ controller.signal.addEventListener("abort", () => parent.removeEventListener("abort", onParentAbort), { once: true });
+ }
return controller.signal;
}